Repository: NVIDIA-AI-Blueprints/video-search-and-summarization Branch: main Commit: aafcc7e6e14c Files: 823 Total size: 5.5 MB Directory structure: gitextract_872b8gvq/ ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report_form.yml │ │ ├── config.yml │ │ ├── documentation_request.yml │ │ └── feature_request_form.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── copy-pr-bot.yaml │ ├── scripts/ │ │ ├── check_copyright_headers.py │ │ └── trigger-downstream-pipeline.sh │ └── workflows/ │ └── ci.yml ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── LICENSE-3rd-party.txt ├── LICENSE.DATA ├── README.md ├── SECURITY.md ├── agent/ │ ├── .gitattributes │ ├── .pre-commit-config.yaml │ ├── AGENTS.md │ ├── LICENSE-3rd-party.txt │ ├── LICENSE.md │ ├── README.md │ ├── docker/ │ │ ├── Dockerfile │ │ ├── cleanup_vulnerabilities.py │ │ └── verify_ffmpeg_tarball.py │ ├── pyproject.toml │ ├── src/ │ │ ├── sitecustomize.py │ │ └── vss_agents/ │ │ ├── __init__.py │ │ ├── agents/ │ │ │ ├── __init__.py │ │ │ ├── critic_agent.py │ │ │ ├── data_models.py │ │ │ ├── multi_report_agent.py │ │ │ ├── postprocessing/ │ │ │ │ ├── __init__.py │ │ │ │ ├── data_models.py │ │ │ │ ├── postprocessing_node.py │ │ │ │ └── validators/ │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── llm_based_rule_validator.py │ │ │ │ ├── non_empty_response_validator.py │ │ │ │ └── url_validator.py │ │ │ ├── register.py │ │ │ ├── report_agent.py │ │ │ ├── search_agent.py │ │ │ └── top_agent.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── custom_fastapi_worker.py │ │ │ ├── health_endpoint.py │ │ │ ├── register.py │ │ │ ├── rtsp_stream_api.py │ │ │ ├── video_delete.py │ │ │ ├── video_search_ingest.py │ │ │ └── video_upload_url.py │ │ ├── data_models/ │ │ │ ├── __init__.py │ │ │ └── vss.py │ │ ├── embed/ │ │ │ ├── __init__.py │ │ │ ├── cosmos_embed.py │ │ │ ├── embed.py │ │ │ └── rtvi_cv_embed.py │ │ ├── evaluators/ │ │ │ ├── __init__.py │ │ │ ├── customized_qa_evaluator/ │ │ │ │ ├── __init__.py │ │ │ │ ├── evaluate.py │ │ │ │ └── register.py │ │ │ ├── customized_trajectory_evaluator/ │ │ │ │ ├── __init__.py │ │ │ │ ├── evaluate.py │ │ │ │ └── register.py │ │ │ ├── evaluate_patch.py │ │ │ ├── register.py │ │ │ ├── report_evaluator/ │ │ │ │ ├── __init__.py │ │ │ │ ├── data_models.py │ │ │ │ ├── eval_config_models.py │ │ │ │ ├── evaluate.py │ │ │ │ ├── field_evaluators/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── common.py │ │ │ │ │ └── llm_judge.py │ │ │ │ └── register.py │ │ │ └── utils.py │ │ ├── prompt.py │ │ ├── py.typed │ │ ├── tools/ │ │ │ ├── __init__.py │ │ │ ├── attribute_search.py │ │ │ ├── chart_generator.py │ │ │ ├── code_executor/ │ │ │ │ ├── __init__.py │ │ │ │ ├── docker_backend/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── docker_executor.py │ │ │ │ │ └── image_builder.py │ │ │ │ └── python_executor.py │ │ │ ├── embed_search.py │ │ │ ├── evaluation_compressor.py │ │ │ ├── fov_counts_with_chart.py │ │ │ ├── geolocation.py │ │ │ ├── incidents.py │ │ │ ├── lvs_video_understanding.py │ │ │ ├── multi_incident_formatter.py │ │ │ ├── prompt_gen.py │ │ │ ├── register.py │ │ │ ├── report_gen.py │ │ │ ├── rtvi_vlm_alert.py │ │ │ ├── s3_picture_url.py │ │ │ ├── search.py │ │ │ ├── template_report_gen.py │ │ │ ├── video_caption.py │ │ │ ├── video_detailed_caption.py │ │ │ ├── video_frame_timestamp.py │ │ │ ├── video_report_gen.py │ │ │ ├── video_skim_caption.py │ │ │ ├── video_understanding.py │ │ │ ├── vss_summarize.py │ │ │ ├── vst/ │ │ │ │ ├── __init__.py │ │ │ │ ├── duration.py │ │ │ │ ├── register.py │ │ │ │ ├── sensor_list.py │ │ │ │ ├── snapshot.py │ │ │ │ ├── timeline.py │ │ │ │ ├── utils.py │ │ │ │ ├── video_clip.py │ │ │ │ └── video_list.py │ │ │ ├── vst_download.py │ │ │ └── vst_files.py │ │ ├── utils/ │ │ │ ├── asyncmixin.py │ │ │ ├── file_mapping.py │ │ │ ├── frame_select.py │ │ │ ├── markdown_parser.py │ │ │ ├── parser.py │ │ │ ├── reasoning_parsing.py │ │ │ ├── reasoning_utils.py │ │ │ ├── retry.py │ │ │ ├── time_convert.py │ │ │ ├── time_measure.py │ │ │ ├── url_translation.py │ │ │ └── video_file.py │ │ └── video_analytics/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── embeddings.py │ │ ├── es_client.py │ │ ├── interface.py │ │ ├── nvschema.py │ │ ├── query_builders.py │ │ ├── tools.py │ │ └── utils.py │ ├── stubs/ │ │ └── nat/ │ │ ├── __init__.pyi │ │ └── data_models/ │ │ ├── __init__.pyi │ │ ├── common.pyi │ │ ├── evaluator.pyi │ │ └── function.pyi │ └── tests/ │ └── unit_test/ │ ├── __init__.py │ ├── agents/ │ │ ├── __init__.py │ │ ├── postprocessing/ │ │ │ ├── __init__.py │ │ │ ├── test_llm_based_rule_validator.py │ │ │ ├── test_non_empty_response_validator.py │ │ │ ├── test_postprocessing_node.py │ │ │ └── test_url_validator.py │ │ ├── test_data_models.py │ │ ├── test_multi_report_agent.py │ │ ├── test_report_agent.py │ │ ├── test_search_agent.py │ │ └── test_top_agent.py │ ├── api/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_health_endpoint_coverage.py │ │ ├── test_rtsp_stream_api.py │ │ ├── test_video_search_ingest.py │ │ ├── test_video_upload_url.py │ │ ├── test_video_upload_url_converters.py │ │ ├── test_video_upload_url_coverage.py │ │ └── test_video_upload_url_inner.py │ ├── conftest.py │ ├── data_models/ │ │ ├── __init__.py │ │ └── test_vss.py │ ├── embed/ │ │ └── test_cosmos_embed.py │ ├── evaluators/ │ │ ├── __init__.py │ │ ├── test_custom_qa.py │ │ ├── test_custom_trajectory.py │ │ ├── test_data_models.py │ │ ├── test_eval_config_models.py │ │ ├── test_evaluate.py │ │ ├── test_evaluate_patch.py │ │ ├── test_field_evaluators.py │ │ ├── test_llm_judge.py │ │ ├── test_llm_judge_coverage.py │ │ ├── test_llm_judge_field_discovery.py │ │ ├── test_register_coverage.py │ │ ├── test_report_evaluator.py │ │ └── test_utils.py │ ├── test_prompt.py │ ├── test_sitecustomize.py │ ├── tools/ │ │ ├── __init__.py │ │ ├── test_build_vst_url.py │ │ ├── test_chart_generator.py │ │ ├── test_chart_generator_converters.py │ │ ├── test_chart_generator_coverage.py │ │ ├── test_chart_generator_inner.py │ │ ├── test_code_executor.py │ │ ├── test_embed_search.py │ │ ├── test_embed_search_coverage.py │ │ ├── test_embed_search_edge_cases.py │ │ ├── test_embed_search_inner.py │ │ ├── test_evaluation_compressor.py │ │ ├── test_fov_counts.py │ │ ├── test_geolocation.py │ │ ├── test_incidents.py │ │ ├── test_lvs_video_understanding.py │ │ ├── test_multi_incident_formatter.py │ │ ├── test_prompt_gen.py │ │ ├── test_prompt_gen_coverage.py │ │ ├── test_prompt_gen_inner.py │ │ ├── test_python_executor.py │ │ ├── test_python_executor_coverage.py │ │ ├── test_report_gen.py │ │ ├── test_rtvi_vlm_alert.py │ │ ├── test_rtvi_vlm_alert_inner.py │ │ ├── test_s3_picture_url.py │ │ ├── test_search.py │ │ ├── test_search_converters.py │ │ ├── test_search_coverage.py │ │ ├── test_search_inner.py │ │ ├── test_search_more_edge_cases.py │ │ ├── test_template_report_gen.py │ │ ├── test_video_caption.py │ │ ├── test_video_caption_coverage.py │ │ ├── test_video_caption_inner.py │ │ ├── test_video_caption_vss_inner.py │ │ ├── test_video_detailed_caption.py │ │ ├── test_video_detailed_caption_coverage.py │ │ ├── test_video_frame_timestamp.py │ │ ├── test_video_frame_timestamp_coverage.py │ │ ├── test_video_report_gen.py │ │ ├── test_video_skim_caption.py │ │ ├── test_video_skim_caption_coverage.py │ │ ├── test_video_understanding.py │ │ ├── test_video_upload_url.py │ │ ├── test_vss_summarize.py │ │ ├── test_vss_summarize_coverage.py │ │ ├── test_vss_summarize_inner.py │ │ ├── test_vst_tools.py │ │ └── vst/ │ │ ├── __init__.py │ │ ├── test_bounding_box.py │ │ ├── test_duration_coverage.py │ │ ├── test_sensor_list.py │ │ ├── test_snapshot.py │ │ ├── test_snapshot_coverage.py │ │ ├── test_snapshot_inner.py │ │ ├── test_stream_list.py │ │ ├── test_timeline.py │ │ ├── test_utils.py │ │ ├── test_video_clip.py │ │ ├── test_video_clip_coverage.py │ │ ├── test_video_clip_inner.py │ │ └── test_video_list_coverage.py │ ├── utils/ │ │ ├── __init__.py │ │ ├── test_asyncmixin.py │ │ ├── test_file_mapping.py │ │ ├── test_frame_select.py │ │ ├── test_markdown_parser.py │ │ ├── test_parser.py │ │ ├── test_reasoning_parsing.py │ │ ├── test_reasoning_utils.py │ │ ├── test_retry.py │ │ ├── test_rewrite_url_host.py │ │ ├── test_screenshot.py │ │ ├── test_time_measure.py │ │ ├── test_url_translation.py │ │ └── test_video_file.py │ └── video_analytics/ │ ├── __init__.py │ ├── test_embeddings.py │ ├── test_es_client.py │ ├── test_interface.py │ ├── test_nvschema.py │ ├── test_query_builders.py │ ├── test_tools.py │ ├── test_tools_deep_coverage.py │ ├── test_tools_edge_cases.py │ ├── test_tools_functions.py │ ├── test_tools_inner.py │ ├── test_tools_inner_fns.py │ ├── test_tools_integration.py │ └── test_utils.py ├── deployments/ │ ├── LICENSE-3rd-party.txt │ ├── MANIFEST │ ├── NOTICE.md │ ├── README.md │ ├── agents/ │ │ ├── agent_ui/ │ │ │ └── compose.yml │ │ ├── compose.yml │ │ └── vss-agent/ │ │ └── vss-agent-docker-compose.yml │ ├── compose.yml │ ├── developer-workflow/ │ │ ├── compose.yml │ │ ├── dev-profile-alerts/ │ │ │ ├── Dockerfiles/ │ │ │ │ ├── EDGE-perception.Dockerfile │ │ │ │ ├── kibana-dashboard.Dockerfile │ │ │ │ └── perception.Dockerfile │ │ │ ├── compose.yml │ │ │ ├── deepstream/ │ │ │ │ ├── EDGE-configs/ │ │ │ │ │ ├── cfg_kafka.txt │ │ │ │ │ ├── coco_classmap.txt │ │ │ │ │ ├── config_triton_nvinferserver_gdino.txt │ │ │ │ │ ├── rtdetr-960x544-labels.txt │ │ │ │ │ ├── rtdetr-960x544.txt │ │ │ │ │ └── run_config-api-rtdetr-protobuf.txt │ │ │ │ ├── configs/ │ │ │ │ │ ├── cfg_kafka.txt │ │ │ │ │ ├── coco_classmap.txt │ │ │ │ │ ├── config_triton_nvinferserver_gdino.txt │ │ │ │ │ ├── rtdetr-960x544-labels.txt │ │ │ │ │ ├── rtdetr-960x544.txt │ │ │ │ │ └── run_config-api-rtdetr-protobuf.txt │ │ │ │ └── init-scripts/ │ │ │ │ └── ds-start.sh │ │ │ ├── kibana-dashboard/ │ │ │ │ ├── init-scripts/ │ │ │ │ │ └── kibana-import-dashboard.sh │ │ │ │ └── its-kibana-objects.ndjson │ │ │ ├── nvstreamer/ │ │ │ │ └── configs/ │ │ │ │ ├── vst-config.json │ │ │ │ └── vst-storage.json │ │ │ ├── sdr/ │ │ │ │ └── docker_cluster_config.json │ │ │ ├── vlm-as-verifier/ │ │ │ │ └── configs/ │ │ │ │ ├── EDGE-LOCAL-VLM-config.yml │ │ │ │ ├── alert_type_config.json │ │ │ │ └── config.yml │ │ │ ├── vss-agent/ │ │ │ │ └── configs/ │ │ │ │ ├── config.yml │ │ │ │ └── va_mcp_server_config.yml │ │ │ ├── vss-behavior-analytics/ │ │ │ │ └── configs/ │ │ │ │ ├── vss-behavior-analytics-kafka-config.json │ │ │ │ └── vss-behavior-analytics-redis-config.json │ │ │ └── vss-video-analytics-api/ │ │ │ └── configs/ │ │ │ ├── video-analytics-api-kafka-config.json │ │ │ └── video-analytics-api-redis-config.json │ │ ├── dev-profile-base/ │ │ │ ├── compose.yml │ │ │ └── vss-agent/ │ │ │ └── configs/ │ │ │ └── config.yml │ │ ├── dev-profile-lvs/ │ │ │ ├── Dockerfiles/ │ │ │ │ └── kibana-dashboard.Dockerfile │ │ │ ├── compose.yml │ │ │ ├── kibana-dashboard/ │ │ │ │ ├── init-scripts/ │ │ │ │ │ └── kibana-import-dashboard.sh │ │ │ │ └── lvs-kibana-objects.ndjson │ │ │ └── vss-agent/ │ │ │ └── configs/ │ │ │ └── config.yml │ │ └── dev-profile-search/ │ │ ├── Dockerfiles/ │ │ │ └── kibana-dashboard.Dockerfile │ │ ├── compose.yml │ │ ├── kibana-dashboard/ │ │ │ ├── init-scripts/ │ │ │ │ └── kibana-import-dashboard.sh │ │ │ └── search-kibana-objects.ndjson │ │ ├── video-analytics-2d-app/ │ │ │ ├── Dockerfiles/ │ │ │ │ └── perception-cnn.Dockerfile │ │ │ ├── compose.yml │ │ │ ├── deepstream/ │ │ │ │ ├── configs/ │ │ │ │ │ ├── cnn-models/ │ │ │ │ │ │ ├── ds-detector-labels.txt │ │ │ │ │ │ ├── ds-kafka-config.txt │ │ │ │ │ │ ├── ds-main-config.txt │ │ │ │ │ │ ├── ds-main-redis-config.txt │ │ │ │ │ │ ├── ds-nvdcf-accuracy-tracker-config.yml │ │ │ │ │ │ ├── ds-ppl-analytics-pgie-config.yml │ │ │ │ │ │ └── ds-redis-config.txt │ │ │ │ │ └── config.csv │ │ │ │ └── init-scripts/ │ │ │ │ └── ds-start.sh │ │ │ ├── nvstreamer/ │ │ │ │ └── configs/ │ │ │ │ ├── vst-config.json │ │ │ │ └── vst-storage.json │ │ │ ├── sdr/ │ │ │ │ └── docker_cluster_config.json │ │ │ └── vss-search-analytics/ │ │ │ └── configs/ │ │ │ ├── vss-search-analytics-kafka-config.json │ │ │ └── vss-search-analytics-redis-config.json │ │ └── vss-agent/ │ │ └── configs/ │ │ └── config.yml │ ├── foundational/ │ │ ├── 3rdParty_Licenses │ │ ├── Dockerfiles/ │ │ │ ├── elastic-init.Dockerfile │ │ │ ├── elasticsearch-gpu.Dockerfile │ │ │ ├── elasticsearch.Dockerfile │ │ │ ├── kafka-health-check.Dockerfile │ │ │ └── redis-health-check.Dockerfile │ │ ├── broker-health-check/ │ │ │ └── scripts/ │ │ │ ├── check-kafka-health.sh │ │ │ └── check-redis-health.sh │ │ ├── elk/ │ │ │ ├── configs/ │ │ │ │ ├── elasticsearch.yml │ │ │ │ ├── kibana.yml │ │ │ │ ├── logstash.yml │ │ │ │ ├── mdx-kafka-logstash.conf │ │ │ │ └── mdx-redis-logstash.conf │ │ │ ├── gems/ │ │ │ │ └── logstash-input-redis_stream-3.1.0-java.gem │ │ │ ├── init-scripts/ │ │ │ │ ├── elasticsearch-ilm-policy-creation.sh │ │ │ │ ├── elasticsearch-ingest-pipeline-creation.sh │ │ │ │ └── elasticsearch-template-creation.sh │ │ │ └── pb_definitions/ │ │ │ ├── descriptors/ │ │ │ │ ├── ext.desc │ │ │ │ └── schema.desc │ │ │ └── ruby/ │ │ │ ├── ext_pb.rb │ │ │ └── schema_pb.rb │ │ ├── kafka/ │ │ │ └── init-scripts/ │ │ │ └── create-kafka-topics.sh │ │ ├── kafka-entrypoint.sh │ │ ├── mdx-foundational.yml │ │ └── redis/ │ │ └── configs/ │ │ └── redis.conf │ ├── lvs/ │ │ ├── README.md │ │ ├── compose.yml │ │ └── configs/ │ │ └── config.yaml │ ├── nim/ │ │ ├── compose.yml │ │ ├── cosmos-reason1-7b/ │ │ │ ├── compose.yml │ │ │ ├── hw-H100-shared.env │ │ │ ├── hw-H100.env │ │ │ ├── hw-L40S.env │ │ │ ├── hw-OTHER-shared.env │ │ │ ├── hw-OTHER.env │ │ │ ├── hw-RTXPRO6000BW-shared.env │ │ │ └── hw-RTXPRO6000BW.env │ │ ├── cosmos-reason2-8b/ │ │ │ ├── compose.yml │ │ │ ├── hw-DGX-SPARK-shared.env │ │ │ ├── hw-DGX-SPARK.env │ │ │ ├── hw-H100-shared.env │ │ │ ├── hw-H100.env │ │ │ ├── hw-L40S.env │ │ │ ├── hw-OTHER-shared.env │ │ │ ├── hw-OTHER.env │ │ │ ├── hw-RTXPRO6000BW-shared.env │ │ │ └── hw-RTXPRO6000BW.env │ │ ├── fallback-override.env │ │ ├── gpt-oss-20b/ │ │ │ ├── compose.yml │ │ │ ├── hw-H100-shared.env │ │ │ ├── hw-H100.env │ │ │ ├── hw-OTHER-shared.env │ │ │ ├── hw-OTHER.env │ │ │ ├── hw-RTXPRO6000BW-shared.env │ │ │ └── hw-RTXPRO6000BW.env │ │ ├── llama-3.3-nemotron-super-49b-v1.5/ │ │ │ ├── compose.yml │ │ │ ├── hw-H100-shared.env │ │ │ ├── hw-H100.env │ │ │ ├── hw-OTHER-shared.env │ │ │ ├── hw-OTHER.env │ │ │ ├── hw-RTXPRO6000BW-shared.env │ │ │ └── hw-RTXPRO6000BW.env │ │ ├── nemotron-3-nano/ │ │ │ ├── compose.yml │ │ │ ├── hw-H100-shared.env │ │ │ ├── hw-H100.env │ │ │ ├── hw-OTHER-shared.env │ │ │ ├── hw-OTHER.env │ │ │ ├── hw-RTXPRO6000BW-shared.env │ │ │ └── hw-RTXPRO6000BW.env │ │ ├── nvidia-nemotron-nano-9b-v2/ │ │ │ ├── compose.yml │ │ │ ├── hw-H100-shared.env │ │ │ ├── hw-H100.env │ │ │ ├── hw-L40S.env │ │ │ ├── hw-OTHER-shared.env │ │ │ ├── hw-OTHER.env │ │ │ ├── hw-RTXPRO6000BW-shared.env │ │ │ └── hw-RTXPRO6000BW.env │ │ ├── nvidia-nemotron-nano-9b-v2-fp8/ │ │ │ ├── compose.yml │ │ │ ├── hw-AGX-THOR-shared.env │ │ │ ├── hw-AGX-THOR.env │ │ │ ├── hw-DGX-SPARK-shared.env │ │ │ ├── hw-DGX-SPARK.env │ │ │ ├── hw-IGX-THOR-shared.env │ │ │ ├── hw-IGX-THOR.env │ │ │ ├── hw-OTHER-shared.env │ │ │ └── hw-OTHER.env │ │ └── qwen3-vl-8b-instruct/ │ │ ├── compose.yml │ │ ├── hw-H100-shared.env │ │ ├── hw-H100.env │ │ ├── hw-OTHER-shared.env │ │ ├── hw-OTHER.env │ │ ├── hw-RTXPRO6000BW-shared.env │ │ └── hw-RTXPRO6000BW.env │ ├── proxy/ │ │ ├── compose.yml │ │ └── nginx.conf.template │ ├── rtvi/ │ │ ├── compose.yml │ │ ├── rtvi-embed/ │ │ │ └── rtvi-embed-docker-compose.yml │ │ └── rtvi-vlm/ │ │ └── rtvi-vlm-docker-compose.yml │ ├── vlm-as-verifier/ │ │ ├── README.md │ │ ├── compose.yml │ │ └── scripts/ │ │ └── env-substitute.py │ └── vst/ │ ├── developer/ │ │ └── vst/ │ │ ├── configs/ │ │ │ ├── adaptor_config.json │ │ │ ├── nginx-mms.conf │ │ │ ├── nginx-mms.conf.template │ │ │ ├── nginx-vst.conf │ │ │ ├── nginx-vst.conf.template │ │ │ ├── postgresql.conf │ │ │ ├── rtsp_streams.json │ │ │ ├── vst_config.json │ │ │ ├── vst_config_kafka.json │ │ │ ├── vst_config_redis.json │ │ │ └── vst_storage.json │ │ ├── docker-compose.yaml │ │ └── sdr-streamprocessing/ │ │ ├── envoy.yaml │ │ ├── sdr-compose.yaml │ │ └── sdr-config/ │ │ ├── data_wl.yaml │ │ └── docker_cluster_config.json │ └── scripts/ │ └── user_additional_install.sh ├── scripts/ │ ├── LICENSE-3rd-party-dev-profile.txt │ ├── deploy_vss_launchable.ipynb │ └── dev-profile.sh └── ui/ ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── CODE-OF-CONDUCT.md ├── CONTRIBUTING.md ├── DOCKER-README.md ├── Dockerfile ├── LICENSE ├── LICENSE-3rd-party.txt ├── README.md ├── SECURITY.md ├── apps/ │ ├── nemo-agent-toolkit-ui/ │ │ ├── .gitignore │ │ ├── __mocks__/ │ │ │ ├── next-i18next.js │ │ │ ├── react-markdown.js │ │ │ └── websocket.ts │ │ ├── __tests__/ │ │ │ ├── api/ │ │ │ │ └── httpEndpoints.test.ts │ │ │ ├── components/ │ │ │ │ ├── Chat.conversation-state.test.tsx │ │ │ │ ├── Chat.error-recovery.test.tsx │ │ │ │ ├── Chat.human-interaction.test.tsx │ │ │ │ ├── Chat.streaming-edge-cases.test.tsx │ │ │ │ ├── Chat.ui-behavior.test.tsx │ │ │ │ ├── Chat.websocket-reliability.test.tsx │ │ │ │ └── Chat.websocket.test.tsx │ │ │ ├── types/ │ │ │ │ └── websocket.test.ts │ │ │ └── utils/ │ │ │ ├── app/ │ │ │ │ └── importExports.test.ts │ │ │ └── chatTransform.test.ts │ │ ├── docs/ │ │ │ └── ui/ │ │ │ ├── README.md │ │ │ ├── button-reference.md │ │ │ ├── chat/ │ │ │ │ └── chat-interface.md │ │ │ ├── settings/ │ │ │ │ └── configuration-management.md │ │ │ └── sidebar/ │ │ │ └── conversation-management.md │ │ ├── next-env.d.ts │ │ ├── next-i18next.config.js │ │ ├── next.config.js │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── _app.tsx │ │ │ ├── _document.tsx │ │ │ ├── api/ │ │ │ │ └── chat.ts │ │ │ └── index.tsx │ │ ├── public/ │ │ │ └── locales/ │ │ │ └── en/ │ │ │ └── common.json │ │ └── tsconfig.json │ └── nv-metropolis-bp-vss-ui/ │ ├── .gitignore │ ├── README.md │ ├── components/ │ │ ├── Home.tsx │ │ ├── ModeControlsSection.tsx │ │ └── TabWithChatSidebarLayout.tsx │ ├── constants/ │ │ └── constants.tsx │ ├── hooks/ │ │ ├── useTabChatSidebarResize.ts │ │ ├── useTabChatSidebars.ts │ │ └── useTheme.ts │ ├── next-env.d.ts │ ├── next-i18next.config.js │ ├── next.config.js │ ├── package.json │ ├── pages/ │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api/ │ │ │ └── chat.ts │ │ └── index.tsx │ ├── postcss.config.js │ ├── public/ │ │ └── locales/ │ │ └── en/ │ │ └── common.json │ ├── styles/ │ │ ├── globals.css │ │ └── rsuite-custom.css │ ├── tailwind.config.js │ ├── tsconfig.json │ └── utils/ │ ├── index.ts │ ├── searchTabChatEnv.ts │ ├── tabChatEnv.ts │ └── tabChatSidebarConfig.ts ├── custom-server.js ├── package.json ├── packages/ │ ├── nemo-agent-toolkit-ui/ │ │ ├── .dockerignore │ │ ├── .eslintrc.js │ │ ├── .gitignore │ │ ├── .swcrc │ │ ├── README.md │ │ ├── TESTING.md │ │ ├── __mocks__/ │ │ │ ├── next-i18next.js │ │ │ ├── react-markdown.js │ │ │ └── websocket.ts │ │ ├── __tests__/ │ │ │ ├── api/ │ │ │ │ └── routes.test.ts │ │ │ ├── components/ │ │ │ │ ├── Chat.conversation-state.test.tsx │ │ │ │ ├── Chat.streaming-edge-cases.test.tsx │ │ │ │ ├── Chat.ui-behavior.test.tsx │ │ │ │ ├── Chat.websocket.test.tsx │ │ │ │ └── InteractionModal.test.tsx │ │ │ ├── proxy/ │ │ │ │ └── proxy-integration.test.js │ │ │ ├── security/ │ │ │ │ ├── json-import-validation.test.ts │ │ │ │ └── url-validation.test.ts │ │ │ ├── types/ │ │ │ │ └── websocket.test.ts │ │ │ └── utils/ │ │ │ ├── app/ │ │ │ │ └── importExports.test.ts │ │ │ └── chatTransform.test.ts │ │ ├── components/ │ │ │ ├── Avatar/ │ │ │ │ ├── AgentAvatar.tsx │ │ │ │ ├── BotAvatar.tsx │ │ │ │ ├── SystemAvatar.tsx │ │ │ │ └── UserAvatar.tsx │ │ │ ├── Buttons/ │ │ │ │ └── SidebarActionButton/ │ │ │ │ ├── SidebarActionButton.tsx │ │ │ │ └── index.ts │ │ │ ├── Chat/ │ │ │ │ ├── Chat.tsx │ │ │ │ ├── ChatFileUpload.tsx │ │ │ │ ├── ChatHeader.tsx │ │ │ │ ├── ChatInput.tsx │ │ │ │ ├── ChatInteractionMessage.tsx │ │ │ │ ├── ChatLoader.tsx │ │ │ │ ├── ChatMessage.tsx │ │ │ │ ├── CustomAgentParams.tsx │ │ │ │ ├── ErrorMessageDiv.tsx │ │ │ │ ├── MemoizedChatMessage.tsx │ │ │ │ ├── README.md │ │ │ │ └── Regenerate.tsx │ │ │ ├── Chatbar/ │ │ │ │ ├── Chatbar.context.tsx │ │ │ │ ├── Chatbar.state.tsx │ │ │ │ ├── Chatbar.tsx │ │ │ │ ├── README.md │ │ │ │ └── components/ │ │ │ │ ├── ChatFolders.tsx │ │ │ │ ├── ChatSidebarContent.tsx │ │ │ │ ├── ChatbarSettings.tsx │ │ │ │ ├── ClearConversations.tsx │ │ │ │ ├── Conversation.tsx │ │ │ │ └── Conversations.tsx │ │ │ ├── Folder/ │ │ │ │ ├── Folder.tsx │ │ │ │ ├── README.md │ │ │ │ └── index.ts │ │ │ ├── Markdown/ │ │ │ │ ├── AgentThink.tsx │ │ │ │ ├── Chart.tsx │ │ │ │ ├── CodeBlock.tsx │ │ │ │ ├── CustomComponents.tsx │ │ │ │ ├── CustomDetails.tsx │ │ │ │ ├── CustomIncidents.tsx │ │ │ │ ├── CustomSummary.tsx │ │ │ │ ├── Image.tsx │ │ │ │ ├── Loading.tsx │ │ │ │ ├── MemoizedReactMarkdown.tsx │ │ │ │ ├── Video.tsx │ │ │ │ └── VideoModal.tsx │ │ │ ├── Mobile/ │ │ │ │ └── Navbar.tsx │ │ │ ├── Search/ │ │ │ │ ├── Search.tsx │ │ │ │ └── index.ts │ │ │ ├── Settings/ │ │ │ │ ├── Import.tsx │ │ │ │ └── SettingDialog.tsx │ │ │ ├── Sidebar/ │ │ │ │ ├── README.md │ │ │ │ ├── Sidebar.tsx │ │ │ │ ├── SidebarButton.tsx │ │ │ │ ├── SidebarInner.tsx │ │ │ │ ├── components/ │ │ │ │ │ └── OpenCloseButton.tsx │ │ │ │ └── index.ts │ │ │ └── Spinner/ │ │ │ ├── Spinner.tsx │ │ │ └── index.ts │ │ ├── config.json │ │ ├── constants/ │ │ │ ├── constants.tsx │ │ │ └── index.ts │ │ ├── hooks/ │ │ │ ├── useConversationOperations.ts │ │ │ ├── useCreateReducer.ts │ │ │ └── useFolderOperations.ts │ │ ├── jest.config.js │ │ ├── jest.setup.js │ │ ├── lib-src/ │ │ │ ├── app.ts │ │ │ ├── contexts/ │ │ │ │ └── RuntimeConfigContext.tsx │ │ │ ├── index.d.ts │ │ │ ├── index.ts │ │ │ ├── server.d.ts │ │ │ └── server.ts │ │ ├── middleware.ts │ │ ├── next-env.d.ts │ │ ├── next-i18next.config.js │ │ ├── next.config.js │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── _app.tsx │ │ │ ├── _document.tsx │ │ │ ├── api/ │ │ │ │ ├── chat.ts │ │ │ │ └── home/ │ │ │ │ ├── home.context.tsx │ │ │ │ ├── home.server.tsx │ │ │ │ ├── home.state.tsx │ │ │ │ ├── home.tsx │ │ │ │ └── index.ts │ │ │ └── index.tsx │ │ ├── postcss.config.js │ │ ├── prettier.config.js │ │ ├── proxy/ │ │ │ ├── request-transformers.js │ │ │ └── response-processors.js │ │ ├── public/ │ │ │ └── locales/ │ │ │ └── en/ │ │ │ ├── common.json │ │ │ └── sidebar.json │ │ ├── styles/ │ │ │ └── globals.css │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.typecheck.json │ │ ├── types/ │ │ │ ├── chat.ts │ │ │ ├── data.ts │ │ │ ├── env.ts │ │ │ ├── error.ts │ │ │ ├── export.ts │ │ │ ├── folder.ts │ │ │ ├── index.ts │ │ │ ├── prompt.ts │ │ │ ├── settings.ts │ │ │ ├── storage.ts │ │ │ └── websocket.ts │ │ ├── utils/ │ │ │ ├── app/ │ │ │ │ ├── api.ts │ │ │ │ ├── clean.ts │ │ │ │ ├── codeblock.ts │ │ │ │ ├── const.ts │ │ │ │ ├── conversation.ts │ │ │ │ ├── folders.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── importExport.ts │ │ │ │ ├── prompts.ts │ │ │ │ └── settings.ts │ │ │ ├── chatTransform.ts │ │ │ ├── data/ │ │ │ │ └── throttle.ts │ │ │ ├── media/ │ │ │ │ └── validation.ts │ │ │ ├── security/ │ │ │ │ ├── import-validation.ts │ │ │ │ ├── oauth-validation.ts │ │ │ │ └── url-validation.js │ │ │ ├── server/ │ │ │ │ ├── apiWrapper.ts │ │ │ │ └── chatApiHandler.ts │ │ │ └── shared/ │ │ │ ├── clipboard.ts │ │ │ ├── formatters.ts │ │ │ └── videoUpload.ts │ │ └── vitest.config.ts │ └── nv-metropolis-bp-vss-ui/ │ ├── README.md │ ├── alerts/ │ │ ├── .gitignore │ │ ├── .swcrc │ │ ├── README.md │ │ ├── __mocks__/ │ │ │ └── @nemo-agent-toolkit-ui.js │ │ ├── __tests__/ │ │ │ ├── components/ │ │ │ │ ├── AlertsComponent.test.tsx │ │ │ │ ├── CustomTimeInput.test.tsx │ │ │ │ ├── FilterControls.test.tsx │ │ │ │ └── FilterTag.test.tsx │ │ │ ├── hooks/ │ │ │ │ ├── useAlerts.test.ts │ │ │ │ ├── useAutoRefresh.test.ts │ │ │ │ ├── useFilters.test.ts │ │ │ │ ├── useTimeWindow.test.ts │ │ │ │ └── useVideoModal.test.ts │ │ │ └── utils/ │ │ │ └── timeUtils.test.ts │ │ ├── jest.config.js │ │ ├── jest.setup.js │ │ ├── lib-src/ │ │ │ ├── AlertsComponent.tsx │ │ │ ├── components/ │ │ │ │ ├── AlertsSidebarControls.tsx │ │ │ │ ├── AlertsTable.tsx │ │ │ │ ├── AutoRefreshControl.tsx │ │ │ │ ├── CustomTimeInput.tsx │ │ │ │ ├── FilterControls.tsx │ │ │ │ ├── FilterTag.tsx │ │ │ │ ├── MetadataSection.tsx │ │ │ │ ├── ThumbnailButton.tsx │ │ │ │ └── TimeFormatSwitch.tsx │ │ │ ├── hooks/ │ │ │ │ ├── useAlerts.ts │ │ │ │ ├── useAutoRefresh.ts │ │ │ │ ├── useFilters.ts │ │ │ │ ├── useTimeWindow.ts │ │ │ │ └── useVideoModal.ts │ │ │ ├── index.ts │ │ │ ├── server.d.ts │ │ │ ├── server.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ └── timeUtils.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ ├── all/ │ │ ├── .gitignore │ │ ├── .swcrc │ │ ├── README.md │ │ ├── lib-src/ │ │ │ ├── index.d.ts │ │ │ ├── index.ts │ │ │ ├── server.d.ts │ │ │ └── server.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ ├── dashboard/ │ │ ├── .gitignore │ │ ├── .swcrc │ │ ├── README.md │ │ ├── lib-src/ │ │ │ ├── DashboardComponent.tsx │ │ │ ├── components/ │ │ │ │ └── DashboardSidebarControls.tsx │ │ │ ├── index.ts │ │ │ ├── server.d.ts │ │ │ └── server.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ ├── map/ │ │ ├── .swcrc │ │ ├── README.md │ │ ├── lib-src/ │ │ │ ├── MapComponent.tsx │ │ │ ├── components/ │ │ │ │ └── MapSidebarControls.tsx │ │ │ ├── index.ts │ │ │ ├── server.d.ts │ │ │ └── server.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ ├── search/ │ │ ├── .gitignore │ │ ├── .swcrc │ │ ├── README.md │ │ ├── __mocks__/ │ │ │ └── @nemo-agent-toolkit-ui.js │ │ ├── __tests__/ │ │ │ ├── components/ │ │ │ │ ├── FilterPopover.test.tsx │ │ │ │ ├── SearchComponent.test.tsx │ │ │ │ ├── SearchHeader.test.tsx │ │ │ │ └── VideoSearchList.test.tsx │ │ │ ├── hooks/ │ │ │ │ ├── useFilter.test.ts │ │ │ │ ├── useSearch.test.ts │ │ │ │ └── useVideoModal.test.ts │ │ │ └── utils/ │ │ │ ├── Formatter.test.ts │ │ │ └── agentResponseParser.test.ts │ │ ├── jest.config.js │ │ ├── jest.setup.js │ │ ├── lib-src/ │ │ │ ├── SearchComponent.tsx │ │ │ ├── components/ │ │ │ │ ├── FilterPopover.tsx │ │ │ │ ├── SearchHeader.tsx │ │ │ │ ├── SearchSidebarControls.tsx │ │ │ │ └── VideoSearchList.tsx │ │ │ ├── hooks/ │ │ │ │ ├── useFilter.ts │ │ │ │ ├── useSearch.ts │ │ │ │ └── useVideoModal.ts │ │ │ ├── index.ts │ │ │ ├── server.d.ts │ │ │ ├── server.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── Formatter.ts │ │ │ └── agentResponseParser.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ └── video-management/ │ ├── .swcrc │ ├── __mocks__/ │ │ └── @nemo-agent-toolkit-ui.js │ ├── __tests__/ │ │ ├── components/ │ │ │ └── StreamsGrid.test.tsx │ │ └── utils/ │ │ └── filterStreams.test.ts │ ├── jest.config.js │ ├── jest.setup.js │ ├── lib-src/ │ │ ├── VideoManagementComponent.tsx │ │ ├── api.ts │ │ ├── components/ │ │ │ ├── AddRtspDialog.tsx │ │ │ ├── AgentUploadDialog.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── LoadingState.tsx │ │ │ ├── StreamCard.tsx │ │ │ ├── StreamsGrid.tsx │ │ │ ├── Toolbar.tsx │ │ │ ├── UploadProgressPanel.tsx │ │ │ ├── VideoManagementSidebarControls.tsx │ │ │ └── index.ts │ │ ├── constants.ts │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ ├── useStorageTimelines.ts │ │ │ └── useStreams.ts │ │ ├── index.ts │ │ ├── rtspStream.ts │ │ ├── server.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── videoDelete.ts │ ├── package.json │ ├── tsconfig.json │ └── tsconfig.lib.json └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ *.mp4 filter=lfs diff=lfs merge=lfs -text *.gem filter=lfs diff=lfs merge=lfs -text ================================================ FILE: .github/CODEOWNERS ================================================ # CODEOWNERS — VSS Blueprint # # Default: all PRs require review from VSS-developers. # Refine per-directory owners as teams are onboarded. # # Docs: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # Default — all files * @NVIDIA-AI-Blueprints/VSS-developers ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report_form.yml ================================================ name: Bug Report description: File a bug report title: "[BUG]: " labels: ["bug", "? - Needs Triage"] body: - type: input id: version attributes: label: Version description: What version of VSS are you running? placeholder: "e.g. 3.1.0" validations: required: true - type: dropdown id: installation attributes: label: Installation method options: - Docker Compose - Source build - Other validations: required: true - type: textarea id: description attributes: label: Describe the bug description: A clear and concise description of the bug. validations: required: true - type: textarea id: reproduction attributes: label: Steps to reproduce description: Steps to reproduce the behavior. placeholder: | 1. Deploy with '...' 2. Send request '...' 3. See error validations: required: true - type: textarea id: expected attributes: label: Expected behavior description: A clear description of what you expected to happen. - type: textarea id: logs attributes: label: Relevant log output description: Please paste any relevant log output. render: shell - type: textarea id: env attributes: label: Environment details description: | Include relevant details about your environment: - OS and version - GPU model and driver version - Docker version - Any relevant configuration - type: checkboxes id: terms attributes: label: Code of Conduct options: - label: I agree to follow this project's [Code of Conduct](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/blob/main/CODE_OF_CONDUCT.md) required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Ask a Question url: https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/discussions about: Please ask any questions here. ================================================ FILE: .github/ISSUE_TEMPLATE/documentation_request.yml ================================================ name: Documentation Request description: Report incorrect or missing documentation title: "[DOC]: " labels: ["documentation", "? - Needs Triage"] body: - type: dropdown id: request-type attributes: label: Is this a correction or a request for new documentation? options: - Correction / Update - New Documentation validations: required: true - type: input id: doc-link attributes: label: Link to existing documentation (if applicable) placeholder: "https://..." - type: textarea id: description attributes: label: Describe the issue or what documentation is needed description: Provide details about what is incorrect, outdated, or missing. validations: required: true - type: textarea id: proposed attributes: label: Proposed correction or content description: If you have a suggestion for the correction or new content, describe it here. - type: checkboxes id: terms attributes: label: Code of Conduct options: - label: I agree to follow this project's [Code of Conduct](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/blob/main/CODE_OF_CONDUCT.md) required: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request_form.yml ================================================ name: Feature Request description: Suggest a new feature or enhancement title: "[FEA]: " labels: ["feature request", "? - Needs Triage"] body: - type: dropdown id: request-type attributes: label: Is this a new feature, an improvement, or a change to existing functionality? options: - New Feature - Improvement - Change validations: required: true - type: dropdown id: priority attributes: label: How would you describe the priority of this feature request? options: - Critical (currently preventing work) - High - Medium - Low (nice-to-have) - type: textarea id: problem attributes: label: Is your feature request related to a problem? Please describe. description: A clear and concise description of the problem. placeholder: "I'm frustrated when..." validations: required: true - type: textarea id: solution attributes: label: Describe the solution you'd like description: A clear description of what you want to happen. validations: required: true - type: textarea id: alternatives attributes: label: Describe alternatives you've considered description: Any alternative solutions or features you've considered. - type: textarea id: context attributes: label: Additional context description: Add any other context, screenshots, or examples. - type: checkboxes id: terms attributes: label: Code of Conduct options: - label: I agree to follow this project's [Code of Conduct](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/blob/main/CODE_OF_CONDUCT.md) required: true ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description ## Checklist - [ ] I am familiar with the [Contributing Guidelines](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/blob/HEAD/CONTRIBUTING.md). - [ ] New or existing tests cover these changes. - [ ] The documentation is up to date with these changes. ================================================ FILE: .github/copy-pr-bot.yaml ================================================ enabled: true auto_sync_draft: false auto_sync_ready: true additional_vetters: - akshayanv - ayyappa-dev - daviddu0425 - freshyjmp - hugoverjus - jiayin-nvidia - kaushikc-nvidia - liamy-nv - mansiigo - nv-mpandele - prisrivastav-nv - soumilinandi - ssmmoo1 - vineet-raina - zac-wang-nv ================================================ FILE: .github/scripts/check_copyright_headers.py ================================================ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Check that source files contain an SPDX copyright header. Scans Python (.py) and TypeScript/JavaScript (.ts, .tsx, .js, .jsx) files tracked by git. Files matching EXCLUDE_PATTERNS are skipped. Exit code 0 if all files pass, 1 if any are missing headers. """ from __future__ import annotations import fnmatch import subprocess import sys from pathlib import Path # SPDX identifier that must appear in the first 5 lines of each file REQUIRED_MARKER = "SPDX-License-Identifier" # File extensions to check CHECK_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx"} # Glob patterns to skip (relative to repo root) EXCLUDE_PATTERNS = ( # Auto-generated / third-party "**/node_modules/**", "**/__pycache__/**", "**/.venv/**", "**/3rdparty/**", # Next.js generated type declarations "**/*-env.d.ts", "**/next-env.d.ts", # Config files that are too short for headers "**/.eslintrc.js", # Lock files "**/uv.lock", "**/package-lock.json", # Stubs (third-party type stubs) "**/stubs/**", # UI — original MIT-licensed code; headers will be added incrementally "ui/**", "services/ui/**", ) def git_ls_files() -> list[str]: """Return all git-tracked files.""" result = subprocess.run( ["git", "ls-files"], capture_output=True, text=True, check=True, ) return result.stdout.strip().splitlines() def is_excluded(path: str) -> bool: """Check if path matches any exclude pattern.""" return any(fnmatch.fnmatch(path, pat) for pat in EXCLUDE_PATTERNS) def has_spdx_header(filepath: str) -> bool: """Check if the first 5 lines contain the SPDX marker.""" try: with open(filepath, encoding="utf-8", errors="ignore") as f: for i, line in enumerate(f): if i >= 5: break if REQUIRED_MARKER in line: return True except (OSError, UnicodeDecodeError): return True # skip unreadable files return False def main() -> int: files = git_ls_files() missing: list[str] = [] for filepath in files: ext = Path(filepath).suffix if ext not in CHECK_EXTENSIONS: continue if is_excluded(filepath): continue if not has_spdx_header(filepath): missing.append(filepath) if missing: print(f"ERROR: {len(missing)} file(s) missing SPDX copyright header:\n") for f in sorted(missing): print(f" {f}") print(f"\nExpected '{REQUIRED_MARKER}' in the first 5 lines.") print("See CONTRIBUTING.md for the required header format.") return 1 print(f"OK: All {len(files)} tracked files checked — no missing headers.") return 0 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: .github/scripts/trigger-downstream-pipeline.sh ================================================ #!/usr/bin/env python3 import json import os import sys from typing import Any from urllib.error import ContentTooShortError from urllib.error import HTTPError from urllib.error import URLError from urllib.parse import quote from urllib.parse import urlencode from urllib.request import Request from urllib.request import urlopen def emit_error(message: str) -> None: print(f"::error::{message}", file=sys.stderr) def add_mask(value: str) -> None: if value: print(f"::add-mask::{value}") def require_env(name: str) -> str: value = os.environ.get(name, "").strip() if not value: emit_error(f"Missing {name}") raise SystemExit(1) return value def api_base_url(raw_url: str) -> str: base = raw_url.rstrip("/") if not base.endswith("/api/v4"): base = f"{base}/api/v4" return base def request_json(action: str, url: str, token: str, data: bytes | None = None) -> dict[str, Any]: headers = { "PRIVATE-TOKEN": token, "Accept": "application/json", } if data is not None: headers["Content-Type"] = "application/x-www-form-urlencoded" request = Request(url, data=data, headers=headers) try: with urlopen(request) as response: payload = response.read().decode("utf-8") except HTTPError as exc: _ = exc.read() emit_error(f"{action} failed with status {exc.code}") raise SystemExit(1) from exc except (URLError, ContentTooShortError) as exc: _ = exc emit_error(f"{action} failed due to a connection error") raise SystemExit(1) from exc try: parsed = json.loads(payload) except (UnicodeDecodeError, json.JSONDecodeError) as exc: _ = exc emit_error(f"{action} returned an unexpected response") raise SystemExit(1) from exc if not isinstance(parsed, dict): emit_error(f"{action} returned an unexpected response") raise SystemExit(1) return parsed def fetch_project_id(base_url: str, token: str, project_path: str) -> int: encoded_project_path = quote(project_path, safe="") response = request_json("Project lookup", f"{base_url}/projects/{encoded_project_path}", token) return int(response["id"]) def trigger_pipeline( base_url: str, token: str, project_id: int, ref: str, variable_name: str, commit_sha: str, ) -> int: payload = urlencode( [ ("ref", ref), ("variables[][key]", variable_name), ("variables[][value]", commit_sha), ] ).encode("utf-8") response = request_json("Pipeline trigger", f"{base_url}/projects/{project_id}/pipeline", token, data=payload) return int(response.get("iid") or response["id"]) def write_summary(message: str) -> None: summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "").strip() if not summary_path: return with open(summary_path, "a", encoding="utf-8") as summary_file: summary_file.write(f"{message}\n") def main() -> int: try: base_url = api_base_url(require_env("DOWNSTREAM_CI_URL")) token = require_env("DOWNSTREAM_CI_TOKEN") project_path = require_env("DOWNSTREAM_PROJECT_PATH") commit_sha = require_env("GITHUB_SHA") ref = os.environ.get("DOWNSTREAM_REF", "main") variable_name = os.environ.get("DOWNSTREAM_SUBMODULE_HASH_VARIABLE", "VSS_SUBMODULE_HASH") for value in (base_url, token, project_path, ref, variable_name): add_mask(value) project_id = fetch_project_id(base_url, token, project_path) pipeline_number = trigger_pipeline(base_url, token, project_id, ref, variable_name, commit_sha) message = f"Triggered pipeline number {pipeline_number}" print(message) write_summary(message) return 0 except SystemExit: raise except Exception as exc: _ = exc emit_error("Unexpected failure while triggering the downstream pipeline") return 1 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: .github/workflows/ci.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: CI on: push: branches: - main - develop - "pull-request/[0-9]+" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: # --------------------------------------------------------------------------- # Job 1: Python lint (ruff check + ruff format + yamllint) # --------------------------------------------------------------------------- lint: name: Lint (Python) runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: agent steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.6.2" - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libcairo2-dev pkg-config - name: Install dev dependencies run: uv sync --group dev --frozen - name: ruff check run: uv run ruff check . - name: ruff format run: uv run ruff format --check . - name: yamllint run: | uv run yamllint \ -d '{extends: default, rules: {line-length: {max: 120}, document-start: disable, indentation: {spaces: 2, indent-sequences: false}}}' \ $(git ls-files '*.yml' '*.yaml' | grep -v '\.gitlab-ci\.yml') # --------------------------------------------------------------------------- # Job 2: Python type checking (mypy) # --------------------------------------------------------------------------- typecheck: name: Type Check (mypy) runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: agent steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.6.2" - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libcairo2-dev pkg-config - name: Install dev dependencies run: uv sync --group dev --frozen - name: mypy run: uv run mypy src/vss_agents/ # --------------------------------------------------------------------------- # Job 3: Python tests (pytest + coverage) # --------------------------------------------------------------------------- test: name: Test (pytest) runs-on: ubuntu-latest timeout-minutes: 15 defaults: run: working-directory: agent steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.6.2" - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libcairo2-dev pkg-config - name: Install dev dependencies run: uv sync --group dev --frozen - name: pytest timeout-minutes: 10 run: | uv run pytest \ --cov=src/vss_agents \ --cov-report=xml:coverage.xml \ --cov-report=term-missing \ -m "not slow and not integration" - name: Upload coverage report uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: coverage-report path: agent/coverage.xml # --------------------------------------------------------------------------- # Job 4: Security scanning (detect-secrets) # --------------------------------------------------------------------------- security: name: Security Scan (detect-secrets) runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: agent steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - name: Install detect-secrets run: python -m pip install detect-secrets==1.5.0 - name: detect-secrets run: | detect-secrets-hook --no-verify \ --exclude-files '(gitleaks-baseline\.json|^deployments/MANIFEST$)' \ $(git ls-files) # --------------------------------------------------------------------------- # Job 5: Frontend lint + typecheck # --------------------------------------------------------------------------- frontend-lint: name: Lint & Typecheck (UI) runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: ui steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "lts/*" cache: "npm" cache-dependency-path: ui/package-lock.json - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: Typecheck run: npm run typecheck # --------------------------------------------------------------------------- # Job 6: Frontend build # --------------------------------------------------------------------------- frontend-build: name: Build (UI) runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: ui steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "lts/*" cache: "npm" cache-dependency-path: ui/package-lock.json - name: Install dependencies run: npm ci - name: Build run: npm run build # --------------------------------------------------------------------------- # Job 7: Copyright header check # --------------------------------------------------------------------------- copyright-headers: name: Copyright Headers runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Check SPDX headers run: python3 .github/scripts/check_copyright_headers.py # --------------------------------------------------------------------------- # Job 8: DCO sign-off check # --------------------------------------------------------------------------- dco: name: DCO Sign-off runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Check DCO sign-off run: | MERGE_BASE=$(git merge-base HEAD origin/${GITHUB_BASE_REF:-main} 2>/dev/null || echo HEAD~1) COMMITS=$(git rev-list "$MERGE_BASE"..HEAD 2>/dev/null || echo "") if [ -z "$COMMITS" ]; then echo "No new commits to check." exit 0 fi FAILED=0 for sha in $COMMITS; do MSG=$(git log -1 --format="%B" "$sha") if ! echo "$MSG" | grep -q "^Signed-off-by:"; then echo "FAIL: Commit $sha missing DCO sign-off" echo " Subject: $(git log -1 --format='%s' "$sha")" FAILED=1 fi done if [ "$FAILED" -eq 1 ]; then echo "" echo "All commits must include a Signed-off-by line." echo "Use: git commit -s -m 'your message'" echo "Or amend: git commit --amend -s --no-edit" exit 1 fi echo "OK: All commits have DCO sign-off." # --------------------------------------------------------------------------- # Job 9: Dependency license check (Python) # --------------------------------------------------------------------------- license-check-python: name: License Check (Python) runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: agent steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.6.2" - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libcairo2-dev pkg-config - name: Install dependencies run: uv sync --frozen - name: Install pip-licenses run: uv run pip install pip-licenses - name: Check Python dependency licenses run: | echo "=== Dependency License Report ===" uv run pip-licenses --format=plain --order=license echo "" echo "=== Checking for disallowed licenses ===" DISALLOWED=$(uv run pip-licenses --format=csv | grep -iE 'GPL|AGPL|SSPL|BUSL' | grep -v 'LGPL' || true) if [ -n "$DISALLOWED" ]; then echo "ERROR: Found packages with disallowed licenses:" echo "$DISALLOWED" exit 1 fi echo "OK: No disallowed licenses found." # --------------------------------------------------------------------------- # Job 10: Dependency license check (UI) # --------------------------------------------------------------------------- license-check-ui: name: License Check (UI) runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: ui steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "lts/*" cache: "npm" cache-dependency-path: ui/package-lock.json - name: Install dependencies run: npm ci - name: Check UI dependency licenses run: | # Dump all licenses as JSON, then validate in Node. # OSRB-approved exceptions are filtered by package name prefix. npx license-checker --json --excludePrivatePackages > /tmp/licenses.json node -e ' const licenses = require("/tmp/licenses.json"); const allowed = new Set([ "MIT","Apache-2.0","BSD-2-Clause","BSD-3-Clause","ISC","0BSD", "Unlicense","CC0-1.0","CC-BY-4.0","CC-BY-3.0","Python-2.0", "BlueOak-1.0.0","MPL-2.0" ]); // OSRB-approved exceptions (dynamically linked, reviewed) const excludePrefixes = ["@img/sharp-libvips"]; const failures = []; for (const [pkg, info] of Object.entries(licenses)) { const name = pkg.replace(/@[^@]+$/, ""); if (excludePrefixes.some(p => name.startsWith(p))) continue; const lic = String(info.licenses || "UNKNOWN"); // license-checker appends * when license is inferred from file (e.g. "MIT*") const parts = lic.replace(/[()]/g, "").split(/ OR | AND /); const ok = parts.some(p => allowed.has(p.trim().replace(/\*$/, ""))); if (!ok) failures.push(pkg + ": " + lic); } if (failures.length) { console.error("ERROR: " + failures.length + " package(s) with disallowed licenses:\n"); failures.forEach(f => console.error(" " + f)); process.exit(1); } console.log("OK: " + Object.keys(licenses).length + " packages checked."); ' # --------------------------------------------------------------------------- # Job 11: Trigger downstream pipeline on main # --------------------------------------------------------------------------- trigger-downstream-pipeline: name: Trigger Downstream Pipeline needs: - lint - typecheck - test - security - frontend-lint - frontend-build - copyright-headers - dco - license-check-python - license-check-ui runs-on: self-hosted timeout-minutes: 10 env: DOWNSTREAM_CI_URL: ${{ secrets.DOWNSTREAM_CI_URL }} DOWNSTREAM_CI_TOKEN: ${{ secrets.DOWNSTREAM_CI_TOKEN }} DOWNSTREAM_PROJECT_PATH: hverjus/ci-vss-oss DOWNSTREAM_REF: main DOWNSTREAM_SUBMODULE_HASH_VARIABLE: VSS_SUBMODULE_HASH steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Trigger pipeline run: python3 .github/scripts/trigger-downstream-pipeline.sh ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/trufflesecurity/trufflehog rev: v3.94.2 hooks: - id: trufflehog name: TruffleHog secret scan entry: trufflehog git file://. --since-commit HEAD --results=verified,unknown --fail language: golang stages: [pre-commit] - repo: local hooks: # DCO sign-off check — ensures every commit has Signed-off-by - id: dco-signoff name: DCO sign-off check entry: bash -c 'git log -1 --format="%B" | grep -q "^Signed-off-by" || { echo "ERROR - Commit missing DCO sign-off. Use git commit -s"; exit 1; }' language: system always_run: true pass_filenames: false stages: [pre-commit] ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting GitHub_Conduct@nvidia.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Video Search and Summarization If you are interested in contributing to Video Search and Summarization (VSS), your contributions will fall into the following categories: 1. You want to report a bug, feature request, or documentation issue - File an [issue](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/issues/new/choose) describing what you encountered or what you want to see changed. - The team will evaluate the issues and triage them, scheduling them for a release. If you believe the issue needs priority attention, comment on the issue to notify the team. 2. You want to propose a new feature and implement it - Post about your intended feature, and we shall discuss the design and implementation. - Once we agree that the plan looks good, go ahead and implement it, using the [code contributions](#code-contributions) guide below. 3. You want to implement a feature or bug-fix for an outstanding issue - Follow the [code contributions](#code-contributions) guide below. - If you need more context on a particular issue, please ask and we shall provide. ## Licensing This project uses a dual-license model: - **Apache-2.0** — applies to all code in the repository except the `ui/` directory. - **MIT** — applies to the original code under the `ui/` directory, which is derived from [NVIDIA NeMo Agent Toolkit UI](https://github.com/NVIDIA/NeMo-Agent-Toolkit-UI/). **All contributions to this repository, regardless of which directory they target, are accepted under the Apache-2.0 license.** Even if you are contributing changes to the `ui/` directory, your contribution will be licensed under Apache-2.0. The original `ui/` code retains its MIT license, but any additions or modifications contributed through this repository are Apache-2.0. See the [LICENSE](LICENSE) file for the full license texts. ### Developer Certificate of Origin (DCO) All contributions must include a DCO sign-off. By adding a `Signed-off-by` line to your commit messages, you certify that you wrote (or otherwise have the right to submit) the contribution, and that you are licensing it under the Apache-2.0 license. To sign off, add the `-s` flag when committing: ```bash git commit -s -m "Your commit message" ``` This appends a line like: ``` Signed-off-by: Your Name ``` If you have already made commits without a sign-off, you can amend the most recent one: ```bash git commit --amend -s --no-edit ``` **Pull requests with unsigned commits will not be merged.** ## Pre-commit hooks This repository uses [pre-commit](https://pre-commit.com/) hooks to run security scans before each commit. You must install and enable them before contributing. ### Setup ```bash pip install pre-commit pre-commit install ``` ### What runs | Hook | Purpose | |------|---------| | [TruffleHog](https://github.com/trufflesecurity/trufflehog) | Scans commits for secrets, credentials, and API keys | The hooks run automatically on `git commit`. To run them manually against all files: ```bash pre-commit run --all-files ``` If a hook fails, fix the issue before committing. **Pull requests that contain detected secrets will not be merged.** ## Code contributions ### Your first issue 1. Read the project's [README.md](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/blob/main/README.md) to learn how to set up the development environment. 2. Find an issue to work on. The best way is to look for the [good first issue](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) or [help wanted](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) labels. 3. Comment on the issue saying you are going to work on it. 4. Code! Make sure to update unit tests! 5. When done, [create your pull request](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/compare). 6. Verify that CI passes all [status checks](https://help.github.com/articles/about-status-checks/), or fix if needed. 7. Wait for other developers to review your code and update code as needed. 8. Once reviewed and approved, a maintainer will merge your pull request. Remember, if you are unsure about anything, don't hesitate to comment on issues and ask for clarifications! ### Pull request guidelines - Provide a clear description of the changes in your PR. - Reference any issues closed by the PR with "closes #1234". - Ensure new or existing tests cover your changes. - Keep the documentation up to date with your changes. ### UI directory — no external contributions The `ui/` directory is maintained internally by the NVIDIA Metropolis UI team and is **not open to external contributions**. Pull requests that modify files under `ui/` from external contributors will be closed. If you find a bug or want to request a feature related to the UI, please [file an issue](https://github.com/NVIDIA-AI-Blueprints/video-search-and-summarization/issues/new/choose) instead. ### Branch naming Branches used to create PRs should have a name of the form `/` which conforms to the following conventions: - Type: - `feat` - For new features - `fix` - For bug fixes - `docs` - For documentation changes - `refactor` - For code refactoring - `test` - For adding or updating tests - Name: - A name to convey what is being worked on - Please use dashes between words as opposed to spaces. ## Attribution Portions adopted from the [NVIDIA PLC-OSS-Template](https://github.com/NVIDIA-GitHub-Management/PLC-OSS-Template). ================================================ FILE: LICENSE ================================================ This project is Apache2 licensed. Code under the ui folder is MIT licensed. Here is the complete license text for Apache-2.0 license : ########################################################################################## Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ########################################################################################## MIT License Copyright (c) 2024 Ivan Fioravanti Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. MIT License Copyright (c) 2024 Mckay Wrigley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: LICENSE-3rd-party.txt ================================================ This file contains the list of third party software with their licenses used in this project. See below files for the licenses: - agent/LICENSE-3rd-party.txt - ui/LICENSE-3rd-party.txt - scripts/LICENSE-3rd-party-dev-profile.txt - deployments/LICENSE-3rd-party.txt ================================================ FILE: LICENSE.DATA ================================================ NVIDIA ASSET LICENSE IMPORTANT NOTICE – PLEASE READ AND AGREE BEFORE USING THE ASSETS. This license agreement (“Agreement”) is a legal agreement between you, whether an individual or entity ("you”) and NVIDIA Corporation ("NVIDIA") and governs your use of the NVIDIA data provided hereunder (the “ASSETS”). This Agreement can be accepted only by an adult of legal age of majority in the country in which the ASSETS is used. If you are under the legal age of majority, you must ask your parent or legal guardian to consent to this Agreement. If you don’t have the required age or authority to accept this Agreement or if you don’t accept all the terms and conditions of this Agreement, do not use the ASSETS. You agree to use the ASSETS only for purposes that are permitted by this Agreement and any applicable law or regulation in the relevant jurisdictions. 1. License. Subject to the terms of this Agreement, NVIDIA grants you a non-exclusive, revocable, non-transferable, non-sublicensable license to use the ASSETS, reproduce the ASSETS and prepare derivative works based on the ASSETS (“Derivative Works”), in each case solely for you to perform a trial or demonstration. The ASSETS include images and other information. The information provided is for example purposes, and may not correspond to actual information regarding the corresponding images. 2. Limitations. Your license to use the ASSETS and Derivative Works is restricted as follows: (i) you may not change or remove copyright, watermarks, or other proprietary notices in the ASSETS and Derivative Works, or otherwise attempt to mislead others about the origin of the ASSETS; (ii) you may not sell, rent, sublicense, transfer, distribute, or otherwise make the ASSETS and Derivative Works available to others; and (iii) you may not deploy the ASSETS as part of a commercial product or service or directly or indirectly create, train, test or improve AI models or artificial intelligent systems using the ASSETS, including any architectures, models, or weights." 3. Ownership. The ASSETS, including all intellectual property rights, is and will remain the sole and exclusive property of NVIDIA or its licensors. Except as expressly granted in this Agreement, (i) NVIDIA reserves all rights, interests, and remedies in connection with the ASSETS and Derivative Works, and (ii) no other license or right is granted to you by implication, estoppel or otherwise. 4. Feedback. You may, but you are not obligated to, provide suggestions, requests, fixes, modifications, enhancements, or other feedback regarding the ASSETS (collectively, “Feedback”). Feedback, even if designated as confidential by you, will not create any confidentiality obligation for NVIDIA or its affiliates. If you provide Feedback, you hereby grant NVIDIA, its affiliates and its designees a non-exclusive, perpetual, irrevocable, sublicensable, worldwide, royalty-free, fully paid-up and transferable license, under your intellectual property rights, to publicly perform, publicly display, reproduce, use, make, have made, sell, offer for sale, distribute (through multiple tiers of distribution), import, create derivative works of and otherwise commercialize and exploit the Feedback at NVIDIA’s discretion. 5. Term and Termination. This Agreement expires twelve (12) months after the date of initial delivery or download of the ASSET. This Agreement will automatically terminate without notice from NVIDIA if you fail to comply with any of the terms in this Agreement or if you commence or participate in any legal proceeding against NVIDIA with respect to the ASSETS. Additionally, either party may terminate this Agreement at any time with prior written notice to the other party. Upon any termination, you must stop using and destroy all copies of the ASSETS and Derivative Works. Upon written request, you will certify in writing that you have complied with your commitments under this section. All provisions will survive termination, except for the licenses granted to you. 6. Disclaimer of Warranties. THE ASSETS ARE PROVIDED BY NVIDIA AS-IS AND WITH ALL FAULTS. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, NVIDIA DISCLAIMS ALL WARRANTIES AND REPRESENTATIONS OF ANY KIND, WHETHER EXPRESS, IMPLIED OR STATUTORY, RELATING TO OR ARISING UNDER THIS AGREEMENT, INCLUDING, WITHOUT LIMITATION, THE WARRANTIES OF TITLE, NONINFRINGEMENT, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, USAGE OF TRADE AND COURSE OF DEALING. 7. Limitations of Liability. 7.1 DISCLAIMER. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL NVIDIA BE LIABLE FOR ANY (I) INDIRECT, PUNITIVE, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, OR (II) DAMAGES FOR THE (A) COST OF PROCURING SUBSTITUTE GOODS OR (B) LOSS OF PROFITS, REVENUES, USE, DATA OR GOODWILL ARISING OUT OF OR RELATED TO THIS AGREEMENT, WHETHER BASED ON BREACH OF CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, OR OTHERWISE, AND EVEN IF NVIDIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES AND EVEN IF A PARTY’S REMEDIES FAIL THEIR ESSENTIAL PURPOSE. 7.2 DAMAGES CAP. ADDITIONALLY, TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, NVIDIA’S TOTAL CUMULATIVE AGGREGATE LIABILITY FOR ANY AND ALL LIABILITIES, OBLIGATIONS OR CLAIMS ARISING OUT OF OR RELATED TO THIS AGREEMENT WILL NOT EXCEED FIVE U.S. DOLLARS (US$5). 8. Governing Law and Jurisdiction. This Agreement will be governed in all respects by the laws of the United States and the laws of the State of Delaware, without regard to conflict of laws principles or the United Nations Convention on Contracts for the International Sale of Goods. The state and federal courts residing in Santa Clara County, California will have exclusive jurisdiction over any dispute or claim arising out of or related to this Agreement, and the parties irrevocably consent to personal jurisdiction and venue in those courts; except that either party may apply for injunctive remedies or an equivalent type of urgent legal relief in any jurisdiction. 9. No Assignment. NVIDIA may assign, delegate or transfer its rights or obligations under this Agreement by any means or operation of law. You may not, without NVIDIA’s prior written consent, assign, delegate or transfer any of your rights or obligations under this Agreement by any means or operation of law, and any attempt to do so is null and void. 10. Export. The ASSETS are subject to United States export laws and regulations. You agree to comply with all applicable export, import, trade and economic sanctions laws and regulations, including the Export Administration Regulations and Office of Foreign Assets Control regulations. These laws include restrictions on destinations, end-users and end-use. 11. Entire Agreement. Regarding the subject matter of this Agreement, the parties agree that this Agreement constitutes the entire and exclusive agreement between the parties and supersedes all prior and contemporaneous communications. If a court of competent jurisdiction rules that a provision of this Agreement is unenforceable, that provision will be deemed modified to the extent necessary to make it enforceable and the remainder of this Agreement will continue in full force and effect. Any amendment to this Agreement must be in writing and signed by authorized representatives of both parties. (v. August 20, 2025) ================================================ FILE: README.md ================================================

NVIDIA AI Blueprint: Video Search and Summarization

### Table of Contents - [Overview](#overview) - [Use Case / Problem Description](#use-case--problem-description) - [Agent Workflows](#agent-workflows) - [Software Components](#software-components) - [Target Audience](#target-audience) - [Repository Structure Overview](#repository-structure-overview) - [Documentation](#documentation) - [Prerequisites](#prerequisites) - [Hardware Requirements](#hardware-requirements) - [Quickstart Guide](#quickstart-guide) - [Contributing](#contributing) - [License](#license) ## Overview This repository is what powers the [build experience](https://build.nvidia.com/nvidia/video-search-and-summarization), showcasing video search and summarization agent with NVIDIA NIM microservices. Insightful, accurate, and interactive video analytics AI agents enable a range of industries to make better decisions faster. These AI agents are given tasks through natural language and can perform complex operations like video summarization and visual question-answering, unlocking entirely new application possibilities. The NVIDIA AI Blueprint makes it easy to get started building and customizing video analytics AI agents for video search and summarization — all powered by generative AI, vision language models (VLMs) like Cosmos Nemotron VLMs, large language models (LLMs) like Llama Nemotron LLMs,d NVIDIA NIM. ## Use Case / Problem Description The NVIDIA AI Blueprint for Video Search and Summarization addresses the challenge of deploying visual agents capable of interacting with large volumes of video data, both stored and streamed. This can be used to create vision AI agents, that can be applied to a multitude of use cases such as monitoring smart spaces, warehouse automation, and SOP validation. This is important where quick and accurate video analysis can lead to better decision-making and enhanced operational efficiency. ## Agent Workflows We provide multiple reference [Agent Workflows](https://docs.nvidia.com/vss/3.1.0/adding-workflows.html) which demonstrate how the individual components can be leveraged by an agent: | Workflow | Description | |----------|-------------| | [Q&A and Report Generation (Quickstart)](https://docs.nvidia.com/vss/3.1.0/quickstart.html) | Video retrieval, VLM-based Q&A, and report generation on short video clips | | [Alert Verification](https://docs.nvidia.com/vss/3.1.0/agent-workflow-alert-verification.html) | Realtime processing of videos using perception (object detection, tracking) and behavior analytics to generate alerts, which are subsequently verified with VLM to reduce false positives | | [Real-Time Alerts](https://docs.nvidia.com/vss/3.1.0/agent-workflow-rt-alert.html) | Continuous processing of video streams through VLM for anomaly detection | | [Video Search](https://docs.nvidia.com/vss/3.1.0/agent-workflow-search.html) | Natural language search across video archives using video embeddings (alpha) | | [Long Video Summarization](https://docs.nvidia.com/vss/3.1.0/agent-workflow-lvs.html) | Analysis and summarization of extended video recordings through chunking and aggregation of dense captions | ## Software Components
1. **NIM microservices**: Here are models used in this blueprint: - [Cosmos-Reason2-8B](https://build.nvidia.com/nvidia/cosmos-reason2-8b) - [NVIDIA Nemotron-Nano-9B-v2](https://build.nvidia.com/nvidia/nvidia-nemotron-nano-9b-v2) 2. **Real-time video intelligence**: The Real-Time Video Intelligence layer extracts rich visual features, semantic embeddings, and contextual understanding from video data in real-time, publishing results to a message broker for downstream analytics and agentic workflows. It provides three core microservices for processing video streams. 3. **Downstream analytics**: The Downstream Analytics layer processes and enriches the metadata streams generated by real-time video intelligence microservices, transforming raw detections into actionable insights and verified alerts. 4. **Agent and offline processing**: The top-level agent leverages the Model Context Protocol (MCP) to access video analytics data, incident records, and vision processing capabilities through a unified tool interface. It integrates multiple vision-based tools including video understanding with Vision Language Models (VLMs), semantic video search using embeddings, long video summarization for extended footage analysis, and video snapshot/clip retrieval. ## Target Audience This blueprint is designed for ease of setup with extensive configuration options, requiring technical expertise. It is intended for: 1. **Video Analysts and IT Engineers:** Professionals focused on analyzing video data and ensuring efficient processing and summarization. The blueprint offers 1-click deployment steps, easy-to-manage configurations, and plug-and-play models, making it accessible for early developers. 2. **GenAI Developers / Machine Learning Engineers:** Experts who need to customize the blueprint for specific use cases. This includes modifying the pipelines for unique datasets and fine-tuning LLMs as needed. For advanced users, the blueprint provides detailed configuration options and custom deployment possibilities, enabling extensive customization and optimization. ## Repository Structure Overview | Directory | Description | |-----------|-------------| | `agent/` | Video search and summarization agent (Python). Contains `src/vss_agents/` (tools, agents, APIs, embeddings, evaluators, video analytics), `tests/`, `stubs/`, `docker/`, and `3rdparty/`. See [agent/README.md](agent/README.md). | | `deployments/` | Deployment configs and Docker Compose: NIM model configs (`nim/`), developer workflows (`developer-workflow/` — dev-profile-base, dev-profile-search, dev-profile-alerts, dev-profile-lvs), foundational services, LVS, RTVI, VLM-as-verifier, VST, and root `compose.yml`. | | `scripts/` | Deployment and patch scripts, including the Brev launchable notebook (`deploy_vss_launchable.ipynb`) and dev-profile / patch scripts. | | `ui/` | Frontend monorepo (Next.js, Turbo): `apps/` (nemo-agent-toolkit-ui, nv-metropolis-bp-vss-ui) and shared `packages/`. See [ui/README.md](ui/README.md). | ## Documentation For detailed instructions and additional information about this blueprint, please refer to the [official documentation](https://docs.nvidia.com/vss/3.1.0/index.html). ## Prerequisites ### Obtain API Key - NVIDIA AI Enterprise developer licence required to local host NVIDIA NIM. - API catalog keys: - NVIDIA [API catalog](https://build.nvidia.com/) or [NGC](https://org.ngc.nvidia.com/setup/api-keys) ([steps to generate key](https://docs.nvidia.com/ngc/gpu-cloud/ngc-user-guide/index.html#generating-api-key)) ## Hardware Requirements The platform requirement can vary depending on the configuration and deployment topology used for VSS and dependencies like VLM, LLM, etc. For a list of validated GPU topologies and what configuration to use, see the [GPU requirements](https://docs.nvidia.com/vss/3.1.0/prerequisites.html#development-profile-gpu-requirements). ## Quickstart Guide ### Launchable Deployment **Ideal for:** Quickly getting started with your own videos without worrying about hardware and software requirements. Follow the steps from the [documentation](https://docs.nvidia.com/vss/3.1.0/cloud-brev.html) and notebook in [scripts](scripts/) directory to complete all pre-requisites and deploy the blueprint using Brev Launchable in a 2xRTX PRO 6000 SE AWS instance. - [scripts/deploy_vss_launchable.ipynb](scripts/deploy_vss_launchable.ipynb): This notebook is tailored specifically for the AWS CSP which uses Ephemeral storage. ### Docker Compose Deployment **Ideal for:** Deploying a VSS agent on your own hardware or bare metal cloud instance. #### System Requirements - OS: - x86 hosts: Ubuntu 22.04 or Ubuntu 24.04 - DGX-SPARK: DGX OS 7.4.0 - IGX-THOR: Jetson Linux BSP (Rel 38.5) - AGX-THOR: Jetson Linux BSP (Rel 38.4) - NVIDIA Driver: - 580.105.08 (x86 hosts with Ubuntu 24.04) - 580.65.06 (x86 hosts with Ubuntu 22.04) - 580.95.05 (DGX-SPARK) - 580.00 (IGX-THOR and AGX-THOR) - NVIDIA Container Toolkit: 1.17.8+ - Docker: 27.2.0+ - Docker Compose: v2.29.0+ - NGC CLI: 4.10.0+ Please refer to [Prerequisites section here for installation details](https://docs.nvidia.com/vss/3.1.0/prerequisites.html). ## Contributing This project is currently in early access and not accepting contributions. Once made generally available, this project will accept contributions. ## License Refer to [LICENSE](LICENSE) ================================================ FILE: SECURITY.md ================================================ ## Security NVIDIA is dedicated to the security and trust of our software products and services, including all source code repositories managed through our organization. If you need to report a security issue, please use the appropriate contact points outlined below. **Please do not report security vulnerabilities through GitHub.** ## Reporting Potential Security Vulnerability in an NVIDIA Product To report a potential security vulnerability in any NVIDIA product: - Web: [Security Vulnerability Submission Form](https://www.nvidia.com/object/submit-security-vulnerability.html) - E-Mail: psirt@nvidia.com - We encourage you to use the following PGP key for secure email communication: [NVIDIA public PGP Key for communication](https://www.nvidia.com/en-us/security/pgp-key) - Please include the following information: - Product/Driver name and version/branch that contains the vulnerability - Type of vulnerability (code execution, denial of service, buffer overflow, etc.) - Instructions to reproduce the vulnerability - Proof-of-concept or exploit code - Potential impact of the vulnerability, including how an attacker could exploit the vulnerability While NVIDIA currently does not have a bug bounty program, we do offer acknowledgement when an externally reported security issue is addressed under our coordinated vulnerability disclosure policy. Please visit our [Product Security Incident Response Team (PSIRT)](https://www.nvidia.com/en-us/security/psirt-policies/) policies page for more information. ## NVIDIA Product Security For all security-related concerns, please visit NVIDIA's Product Security portal at https://www.nvidia.com/en-us/security ================================================ FILE: agent/.gitattributes ================================================ *.mp4 filter=lfs diff=lfs merge=lfs -text 3rdparty/ffmpeg/FFmpeg-n8.0.1.tar.gz filter=lfs diff=lfs merge=lfs -text ================================================ FILE: agent/.pre-commit-config.yaml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.3.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.6.2 hooks: - id: uv-lock - repo: https://github.com/adrienverge/yamllint rev: v1.35.1 hooks: - id: yamllint args: - '-d' - '{extends: default, rules: {line-length: {max: 120}, document-start: disable, indentation: {spaces: 2, indent-sequences: false}}}' files: \.(yaml|yml)$ exclude: (^\.gitlab-ci\.yml$|^tests/\.gitlab-ci\.yml$) - repo: local hooks: - id: gitleaks name: gitleaks language: docker_image entry: zricethezav/gitleaks:v8.30.0 protect --staged --redact -v --baseline-path gitleaks-baseline.json pass_filenames: false stages: [pre-commit] - repo: https://github.com/Yelp/detect-secrets rev: v1.5.0 hooks: - id: detect-secrets args: ['--baseline', '.secrets.baseline'] exclude: gitleaks-baseline\.json$ - repo: local hooks: - id: mypy name: mypy entry: uv run mypy language: system types: [python] files: ^src/vss_agents/ pass_filenames: false args: [src/vss_agents/] default_language_version: python: python3 ================================================ FILE: agent/AGENTS.md ================================================ # AGENTS.md ## Project Overview NVIDIA VSS Agent — video search, summarization, and incident analysis built on [NVIDIA AIQ Toolkit](https://docs.nvidia.com/nemo/agent-toolkit/latest/index.html). **Tech stack:** Python 3.13, NAT framework (nvidia-nat), LangChain/LangGraph, FastAPI, Pydantic v2, OpenCV, xhtml2pdf. Package manager: `uv`. Linter/formatter: Ruff. Type checker: Mypy. ## Commands ```bash # Setup uv venv --python 3.13 && uv sync --group dev && source .venv/bin/activate sudo apt-get install libcairo2-dev pkg-config python3-dev # PDF generation deps pre-commit install # Test uv run pytest tests/unit_test/ -v # all tests uv run pytest tests/unit_test/tools/test_video_clip.py -v # single file # Lint & type-check (run all three after every change) uv run ruff check src/ # lint uv run ruff check src/vss_agents/tools/video_clip.py # lint single file uv run ruff format --check src/ # format check uv run mypy src/vss_agents/ # type check # Run the agent (dev-profile-base example; see README.md Quick Start) nat serve --config_file ../deployments/developer-workflow/dev-profile-base/vss-agent/configs/config.yml \ --host 0.0.0.0 --port 8000 ``` ## Project Structure ``` src/vss_agents/ ├── agents/ # Orchestration agents (top_agent, report_agent, multi_report_agent) │ └── postprocessing/ # Response validation (URL validator, etc.) ├── api/ # FastAPI endpoints, custom workers, RTSP/video ingest routes ├── data_models/ # Pydantic models shared across modules ├── embed/ # Embedding and vector-search utilities ├── evaluators/ # LLM-judge evaluators (trajectory, QA, report quality) ├── tools/ # NAT tools: video_understanding, report_gen, geolocation, … │ ├── vst/ # Video Storage Toolkit tools (clip, snapshot, video_list) │ └── code_executor/ # Sandboxed code execution (Docker backend) ├── utils/ # Shared helpers └── video_analytics/ # Video Analytics MCP server and ES client tests/unit_test/ # Mirrors src/ tree — every module has a matching test dir stubs/ # Mypy stubs for NAT framework (nat.data_models) ``` ## Code Style - **Line length**: 120 chars. **Quotes**: double. **Trailing commas**: yes. - **Imports**: one per line, isort-sorted, `force-single-line = true`. - **Type hints**: required on all function signatures. No `Any` without justification. - **Dependencies**: sorted in `pyproject.toml`, `~=` with 2-digit precision (e.g. `~=1.2`). ```python # ✅ Good async def fetch_video_clip(sensor_id: str, start: float, end: float) -> VideoClipResult: if end <= start: raise ValueError(f"end ({end}) must be after start ({start})") return await self._vst_client.get_clip(sensor_id, start, end) # ❌ Bad — missing types, vague name, no validation async def get(id, s, e): return await self._vst_client.get_clip(id, s, e) ``` **License header** (required on every Python file): ```python # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ``` ## Architecture Patterns - **Tools** subclass `FunctionBaseConfig` (NAT framework). Each tool has a `register.py` entry point listed under `[project.entry-points.'nat.components']` in `pyproject.toml`. - **Agents** are LangGraph state machines (`top_agent.py` routes to tools and sub-agents). - **Config** is YAML with `${ENV_VAR}` substitution. Profiles live in `../deployments/developer-workflow//vss-agent/configs/config.yml`. - **Stubs**: `stubs/` has Mypy stubs for NAT. When subclassing a NAT base config, verify `uv run mypy src/vss_agents/` passes — extend the stub if needed. ## Testing - Unit tests mirror `src/` under `tests/unit_test/`. Adding a new module? Add a matching test file. - Use `pytest-asyncio` for async tests. Mark slow tests with `@pytest.mark.slow`. - Mocking: mock external services (VST, LLM, VLM, Elasticsearch) — never call real endpoints in unit tests. ## Git Workflow - Create a feature branch from `main`. Keep commits focused. - Pre-commit hooks run `ruff`, `gitleaks` (secret scanning), and format checks automatically. - Run `uv run pytest tests/unit_test/ -v` before pushing. ## Boundaries - **Always**: add type hints, add the license header, run `ruff check` + `ruff format --check` + `mypy` after changes, write or update unit tests for new code. - **Ask first**: adding new dependencies to `pyproject.toml`, modifying agent orchestration in `top_agent.py`, changing YAML config schema. - **Never**: commit secrets or API keys, modify files under `3rdparty/`, remove or skip failing tests, hardcode IPs/URLs (use `${ENV_VAR}` in configs). ================================================ FILE: agent/LICENSE-3rd-party.txt ================================================ # Dependencies Licenses This file contains the license texts for all dependencies used in this project. Total packages: 237 --- -------------------------------------------------------------------------------- ## aioboto3 (15.5.0) **License:** Apache-2.0 **License URL:** https://github.com/terricain/aioboto3/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2015-2016 Nikolai Novik Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## aiobotocore (2.25.1) **License:** Apache-2.0 **License URL:** https://github.com/aio-libs/aiobotocore/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2015-2016 Nikolai Novik Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## aiofiles (25.1.0) **License:** Apache-2.0 **License URL:** https://github.com/Tinche/aiofiles/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## aiohappyeyeballs (2.6.1) **License:** PSF-2.0 **License URL:** https://github.com/aio-libs/aiohappyeyeballs/blob/main/LICENSE ``` A. HISTORY OF THE SOFTWARE ========================== Python was created in the early 1990s by Guido van Rossum at Stichting Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands as a successor of a language called ABC. Guido remains Python's principal author, although it includes many contributions from others. In 1995, Guido continued his work on Python at the Corporation for National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) in Reston, Virginia where he released several versions of the software. In May 2000, Guido and the Python core development team moved to BeOpen.com to form the BeOpen PythonLabs team. In October of the same year, the PythonLabs team moved to Digital Creations, which became Zope Corporation. In 2001, the Python Software Foundation (PSF, see https://www.python.org/psf/) was formed, a non-profit organization created specifically to own Python-related Intellectual Property. Zope Corporation was a sponsoring member of the PSF. All Python releases are Open Source (see https://opensource.org for the Open Source Definition). Historically, most, but not all, Python releases have also been GPL-compatible; the table below summarizes the various releases. Release Derived Year Owner GPL- from compatible? (1) 0.9.0 thru 1.2 1991-1995 CWI yes 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes 1.6 1.5.2 2000 CNRI no 2.0 1.6 2000 BeOpen.com no 1.6.1 1.6 2001 CNRI yes (2) 2.1 2.0+1.6.1 2001 PSF no 2.0.1 2.0+1.6.1 2001 PSF yes 2.1.1 2.1+2.0.1 2001 PSF yes 2.1.2 2.1.1 2002 PSF yes 2.1.3 2.1.2 2002 PSF yes 2.2 and above 2.1.1 2001-now PSF yes Footnotes: (1) GPL-compatible doesn't mean that we're distributing Python under the GPL. All Python licenses, unlike the GPL, let you distribute a modified version without making your changes open source. The GPL-compatible licenses make it possible to combine Python with other software that is released under the GPL; the others don't. (2) According to Richard Stallman, 1.6.1 is not GPL-compatible, because its license has a choice of law clause. According to CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 is "not incompatible" with the GPL. Thanks to the many outside volunteers who have worked under Guido's direction to make these releases possible. B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON =============================================================== Python software and documentation are licensed under the Python Software Foundation License Version 2. Starting with Python 3.8.6, examples, recipes, and other code in the documentation are dual licensed under the PSF License Version 2 and the Zero-Clause BSD license. Some software incorporated into Python is under different licenses. The licenses are listed with code falling under that license. PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 -------------------------------------------- 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and the Individual or Organization ("Licensee") accessing and otherwise using this software ("Python") in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, PSF hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python alone or in any derivative version, provided, however, that PSF's License Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; All Rights Reserved" are retained in Python alone or in any derivative version prepared by Licensee. 3. In the event Licensee prepares a derivative work that is based on or incorporates Python or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python. 4. PSF is making Python available to Licensee on an "AS IS" basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between PSF and Licensee. This License Agreement does not grant permission to use PSF trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By copying, installing or otherwise using Python, Licensee agrees to be bound by the terms and conditions of this License Agreement. BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 ------------------------------------------- BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the Individual or Organization ("Licensee") accessing and otherwise using this software in source or binary form and its associated documentation ("the Software"). 2. Subject to the terms and conditions of this BeOpen Python License Agreement, BeOpen hereby grants Licensee a non-exclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use the Software alone or in any derivative version, provided, however, that the BeOpen Python License is retained in the Software, alone or in any derivative version prepared by Licensee. 3. BeOpen is making the Software available to Licensee on an "AS IS" basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 5. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 6. This License Agreement shall be governed by and interpreted in all respects by the law of the State of California, excluding conflict of law provisions. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between BeOpen and Licensee. This License Agreement does not grant permission to use BeOpen trademarks or trade names in a trademark sense to endorse or promote products or services of Licensee, or any third party. As an exception, the "BeOpen Python" logos available at http://www.pythonlabs.com/logos.html may be used according to the permissions granted on that web page. 7. By copying, installing or otherwise using the software, Licensee agrees to be bound by the terms and conditions of this License Agreement. CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 --------------------------------------- 1. This LICENSE AGREEMENT is between the Corporation for National Research Initiatives, having an office at 1895 Preston White Drive, Reston, VA 20191 ("CNRI"), and the Individual or Organization ("Licensee") accessing and otherwise using Python 1.6.1 software in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, CNRI hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python 1.6.1 alone or in any derivative version, provided, however, that CNRI's License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) 1995-2001 Corporation for National Research Initiatives; All Rights Reserved" are retained in Python 1.6.1 alone or in any derivative version prepared by Licensee. Alternately, in lieu of CNRI's License Agreement, Licensee may substitute the following text (omitting the quotes): "Python 1.6.1 is made available subject to the terms and conditions in CNRI's License Agreement. This Agreement together with Python 1.6.1 may be located on the internet using the following unique, persistent identifier (known as a handle): 1895.22/1013. This Agreement may also be obtained from a proxy server on the internet using the following URL: http://hdl.handle.net/1895.22/1013". 3. In the event Licensee prepares a derivative work that is based on or incorporates Python 1.6.1 or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python 1.6.1. 4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. This License Agreement shall be governed by the federal intellectual property law of the United States, including without limitation the federal copyright law, and, to the extent such U.S. federal law does not apply, by the law of the Commonwealth of Virginia, excluding Virginia's conflict of law provisions. Notwithstanding the foregoing, with regard to derivative works based on Python 1.6.1 that incorporate non-separable material that was previously distributed under the GNU General Public License (GPL), the law of the Commonwealth of Virginia shall govern this License Agreement only as to issues arising under or with respect to Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between CNRI and Licensee. This License Agreement does not grant permission to use CNRI trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By clicking on the "ACCEPT" button where indicated, or by copying, installing or otherwise using Python 1.6.1, Licensee agrees to be bound by the terms and conditions of this License Agreement. ACCEPT CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 -------------------------------------------------- Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, The Netherlands. All rights reserved. Permission to use, copy, modify, and distribute this software and its documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appear in all copies and that both that copyright notice and this permission notice appear in supporting documentation, and that the name of Stichting Mathematisch Centrum or CWI not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission. STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION ---------------------------------------------------------------------- Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` -------------------------------------------------------------------------------- ## aiohttp (3.13.2) **License:** Apache-2.0 AND MIT **License URL:** https://github.com/aio-libs/aiohttp/blob/master/LICENSE.txt ``` Copyright aio-libs contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## aioitertools (0.13.0) **License:** MIT **License URL:** https://github.com/omnilib/aioitertools/blob/main/LICENSE ``` MIT License Copyright (c) 2022 Amethyst Reese Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## aiorwlock (1.5.0) **License:** Apache-2.0 **License URL:** https://github.com/aio-libs/aiorwlock/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2015-2019 Andrew Svetlov, Nikolay Novik Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## aiosignal (1.4.0) **License:** Apache 2.0 **License URL:** https://github.com/aio-libs/aiosignal/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2013-2019 Nikolay Kim and Andrew Svetlov Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## aiosqlite (0.21.0) **License:** MIT License **License URL:** https://github.com/omnilib/aiosqlite/blob/main/LICENSE ``` MIT License Copyright (c) 2022 Amethyst Reese Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## alembic (1.17.2) **License:** MIT **License URL:** https://github.com/sqlalchemy/alembic/blob/main/LICENSE ``` Copyright 2009-2026 Michael Bayer. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## annotated-doc (0.0.4) **License:** MIT **License URL:** https://github.com/fastapi/annotated-doc/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2025 Sebastián Ramírez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## annotated-types (0.7.0) **License:** MIT License **License URL:** https://github.com/annotated-types/annotated-types/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2022 the contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## anyio (4.12.0) **License:** MIT **License URL:** https://github.com/agronholm/anyio/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2018 Alex Grönholm Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## appdirs (1.4.4) **License:** MIT **License URL:** https://github.com/ActiveState/appdirs/blob/master/LICENSE.txt ``` The MIT License (MIT) Copyright (c) 2010 ActiveState Software Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## arabic-reshaper (3.0.0) **License:** MIT **License URL:** https://github.com/mpcabd/python-arabic-reshaper/blob/master/LICENSE ``` MIT License Copyright (c) 2019 Abdullah Diab Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## arize-phoenix-otel (0.13.1) **License:** Elastic 2.0 **License URL:** https://github.com/Arize-ai/phoenix/blob/main/LICENSE ``` Elastic License 2.0 (ELv2) **Acceptance** By using the software, you agree to all of the terms and conditions below. **Copyright License** The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations and conditions below. **Limitations** You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software. You may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key. You may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law. **Patents** The licensor grants you a license, under any patent claims the licensor can license, or becomes able to license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case subject to the limitations and conditions in this license. This license does not cover any patent claims that you cause to be infringed by modifications or additions to the software. If you or your company make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. **Notices** You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software prominent notices stating that you have modified the software. **No Other Rights** These terms do not imply any licenses other than those expressly granted in these terms. **Termination** If you use the software in violation of these terms, such use is not licensed, and your licenses will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your licenses will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your licenses to terminate automatically and permanently. **No Liability** As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim. **Definitions** The *licensor* is the entity offering these terms, and the *software* is the software the licensor makes available under these terms, including any portion of it. *you* refers to the individual or entity agreeing to these terms. *your company* is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. *control* means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. *your licenses* are all the licenses granted to you for the software under these terms. *use* means anything you do with the software requiring one of your licenses. *trademark* means trademarks, service marks, and similar rights. ``` -------------------------------------------------------------------------------- ## asn1crypto (1.5.1) **License:** MIT **License URL:** https://github.com/wbond/asn1crypto/blob/master/LICENSE ``` Copyright (c) 2015-2022 Will Bond Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## attrs (25.4.0) **License:** MIT **License URL:** https://github.com/python-attrs/attrs/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2015 Hynek Schlawack and the attrs contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## authlib (1.6.5) **License:** BSD-3-Clause **License URL:** https://github.com/authlib/authlib/blob/main/LICENSE ``` BSD 3-Clause License Copyright (c) 2017, Hsiaoming Yang All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## beautifulsoup4 (4.14.3) **License:** MIT License **License URL:** https://github.com/wention/BeautifulSoup4?tab=License-1-ov-file ``` Beautiful Soup is made available under the MIT license: Copyright (c) Leonard Richardson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Beautiful Soup incorporates code from the html5lib library, which is also made available under the MIT license. Copyright (c) James Graham and other contributors Beautiful Soup has an optional dependency on the soupsieve library, which is also made available under the MIT license. Copyright (c) Isaac Muse ``` -------------------------------------------------------------------------------- ## blinker (1.9.0) **License:** MIT **License URL:** https://github.com/pallets-eco/blinker/blob/main/LICENSE.txt ``` Copyright 2010 Jason Kirtland Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## boto3 (1.40.61) **License:** Apache-2.0 **License URL:** https://github.com/boto/boto3/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ``` -------------------------------------------------------------------------------- ## botocore (1.40.61) **License:** Apache-2.0 **License URL:** https://github.com/boto/botocore/blob/master/LICENSE.txt ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ``` -------------------------------------------------------------------------------- ## cachetools (7.0.0) **License:** MIT **License URL:** https://github.com/tkem/cachetools/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2014-2026 Thomas Kemmer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## certifi (2025.11.12) **License:** MPL-2.0 **License URL:** https://github.com/certifi/python-certifi/blob/master/LICENSE ``` This package contains a modified version of ca-bundle.crt: ca-bundle.crt -- Bundle of CA Root Certificates This is a bundle of X.509 certificates of public Certificate Authorities (CA). These were automatically extracted from Mozilla's root certificates file (certdata.txt). This file can be found in the mozilla source tree: https://hg.mozilla.org/mozilla-central/file/tip/security/nss/lib/ckfw/builtins/certdata.txt It contains the certificates in PEM format and therefore can be directly used with curl / libcurl / php_curl, or with an Apache+mod_ssl webserver for SSL client authentication. Just configure this file as the SSLCACertificateFile.# ***** BEGIN LICENSE BLOCK ***** This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. ***** END LICENSE BLOCK ***** @(#) $RCSfile: certdata.txt,v $ $Revision: 1.80 $ $Date: 2011/11/03 15:11:58 $ ``` -------------------------------------------------------------------------------- ## cffi (2.0.0) **License:** MIT **License URL:** https://github.com/python-cffi/cffi/blob/main/LICENSE ``` Except when otherwise stated (look for LICENSE files in directories or information at the beginning of each file) all software and documentation is licensed as follows: MIT No Attribution Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## charset-normalizer (3.4.4) **License:** MIT **License URL:** https://github.com/jawah/charset_normalizer/blob/master/LICENSE ``` MIT License Copyright (c) 2025 TAHRI Ahmed R. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## click (8.3.1) **License:** BSD-3-Clause **License URL:** https://github.com/pallets/click/blob/main/LICENSE.txt ``` Copyright 2014 Pallets Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## cloudpickle (3.1.2) **License:** PiCloud License **License URL:** https://github.com/cloudpipe/cloudpickle/blob/master/LICENSE ``` This module was extracted from the `cloud` package, developed by PiCloud, Inc. Copyright (c) 2015, Cloudpickle contributors. Copyright (c) 2012, Regents of the University of California. Copyright (c) 2009 PiCloud, Inc. http://www.picloud.com. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the University of California, Berkeley nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## colorama (0.4.6) **License:** BSD License **License URL:** https://github.com/tartley/colorama/blob/master/LICENSE.txt ``` Copyright (c) 2010 Jonathan Hartley All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holders, nor those of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## colorlog (6.10.1) **License:** MIT License **License URL:** https://github.com/borntyping/python-colorlog/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2012-2021 Sam Clements Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## contourpy (1.3.3) **License:** BSD-3-Clause **License URL:** https://github.com/contourpy/contourpy/blob/main/LICENSE ``` BSD 3-Clause License Copyright (c) 2021-2026, ContourPy Developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## cryptography (44.0.3) **License:** Apache-2.0 OR BSD-3-Clause **License URL:** https://github.com/pyca/cryptography/blob/main/LICENSE ``` This software is made available under the terms of *either* of the licenses found in LICENSE.APACHE or LICENSE.BSD. Contributions to cryptography are made under the terms of *both* these licenses. === LICENSE.APACHE === Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. === LICENSE.BSD === Copyright (c) Individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of PyCA Cryptography nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## cssselect2 (0.8.0) **License:** BSD License **License URL:** https://github.com/Kozea/cssselect2/blob/main/LICENSE ``` BSD 3-Clause License Copyright (c) 2012-2018, Simon Sapin and contributors (see AUTHORS). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## cycler (0.12.1) **License:** BSD 3-Clause **License URL:** https://github.com/matplotlib/cycler/blob/main/LICENSE ``` Copyright (c) 2015, matplotlib project All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the matplotlib project nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## dask (2023.6.0) **License:** BSD-3-Clause **License URL:** https://github.com/dask/dask/blob/main/LICENSE.txt ``` BSD 3-Clause License Copyright (c) 2014, Anaconda, Inc. and contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## dataclasses-json (0.6.7) **License:** MIT **License URL:** https://github.com/lidatong/dataclasses-json/blob/master/LICENSE ``` MIT License Copyright (c) 2019 Charles Li and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## datasets (4.4.1) **License:** Apache 2.0 **License URL:** https://github.com/huggingface/datasets/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## dill (0.4.0) **License:** BSD-3-Clause **License URL:** https://github.com/uqfoundation/dill/blob/master/LICENSE ``` Copyright (c) 2004-2016 California Institute of Technology. Copyright (c) 2016-2026 The Uncertainty Quantification Foundation. All rights reserved. This software is available subject to the conditions and terms laid out below. By downloading and using this software you are agreeing to the following conditions. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - Neither the names of the copyright holders nor the names of any of the contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## diskcache (5.6.3) **License:** Apache 2.0 **License URL:** https://github.com/grantjenks/python-diskcache/blob/master/LICENSE ``` Copyright 2016-2022 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## distributed (2023.6.0) **License:** BSD-3-Clause **License URL:** https://github.com/dask/distributed/blob/main/LICENSE.txt ``` BSD 3-Clause License Copyright (c) 2015, Anaconda, Inc. and contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## distro (1.9.0) **License:** Apache License, Version 2.0 **License URL:** https://github.com/python-distro/distro/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## docker (7.1.0) **License:** Apache Software License **License URL:** https://github.com/docker/docker-py/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2016 Docker, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## docopt (0.6.2) **License:** MIT **License URL:** https://github.com/docopt/docopt/blob/master/LICENSE-MIT ``` Copyright (c) 2012 Vladimir Keleshev, Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## duckdb (1.5.0.dev103) **License:** MIT License **License URL:** https://github.com/duckdb/duckdb-python/blob/main/LICENSE ``` Copyright 2018-2025 Stichting DuckDB Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## elastic-transport (8.17.1) **License:** Apache Software License **License URL:** https://github.com/elastic/elastic-transport-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. ``` -------------------------------------------------------------------------------- ## elasticsearch (8.17.2) **License:** Apache-2.0 **License URL:** https://github.com/elastic/elasticsearch-py/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. ``` -------------------------------------------------------------------------------- ## et-xmlfile (2.0.0) **License:** MIT **License URL:** https://foss.heptapod.net/openpyxl/et_xmlfile/-/blob/branch/default/LICENCE.rst ``` et_xml is licensed under the MIT license; see the file LICENCE for details. et_xml includes code from the Python standard library, which is licensed under the Python license, a permissive open source license. The copyright and license is included below for compliance with Python's terms. This module includes corrections and new features as follows: - Correct handling of attributes namespaces when a default namespace has been registered. - Records the namespaces for an Element during parsing and utilises them to allow inspection of namespaces at specific elements in the xml tree and during serialisation. Misc: - Includes the test_xml_etree with small modifications for testing the modifications in this package. ---------------------------------------------------------------------- Copyright (c) 2001-present Python Software Foundation; All Rights Reserved A. HISTORY OF THE SOFTWARE ========================== Python was created in the early 1990s by Guido van Rossum at Stichting Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands as a successor of a language called ABC. Guido remains Python's principal author, although it includes many contributions from others. In 1995, Guido continued his work on Python at the Corporation for National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) in Reston, Virginia where he released several versions of the software. In May 2000, Guido and the Python core development team moved to BeOpen.com to form the BeOpen PythonLabs team. In October of the same year, the PythonLabs team moved to Digital Creations, which became Zope Corporation. In 2001, the Python Software Foundation (PSF, see https://www.python.org/psf/) was formed, a non-profit organization created specifically to own Python-related Intellectual Property. Zope Corporation was a sponsoring member of the PSF. All Python releases are Open Source (see https://opensource.org for the Open Source Definition). Historically, most, but not all, Python releases have also been GPL-compatible; the table below summarizes the various releases. Release Derived Year Owner GPL- from compatible? (1) 0.9.0 thru 1.2 1991-1995 CWI yes 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes 1.6 1.5.2 2000 CNRI no 2.0 1.6 2000 BeOpen.com no 1.6.1 1.6 2001 CNRI yes (2) 2.1 2.0+1.6.1 2001 PSF no 2.0.1 2.0+1.6.1 2001 PSF yes 2.1.1 2.1+2.0.1 2001 PSF yes 2.1.2 2.1.1 2002 PSF yes 2.1.3 2.1.2 2002 PSF yes 2.2 and above 2.1.1 2001-now PSF yes Footnotes: (1) GPL-compatible doesn't mean that we're distributing Python under the GPL. All Python licenses, unlike the GPL, let you distribute a modified version without making your changes open source. The GPL-compatible licenses make it possible to combine Python with other software that is released under the GPL; the others don't. (2) According to Richard Stallman, 1.6.1 is not GPL-compatible, because its license has a choice of law clause. According to CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 is "not incompatible" with the GPL. Thanks to the many outside volunteers who have worked under Guido's direction to make these releases possible. B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON =============================================================== Python software and documentation are licensed under the Python Software Foundation License Version 2. Starting with Python 3.8.6, examples, recipes, and other code in the documentation are dual licensed under the PSF License Version 2 and the Zero-Clause BSD license. Some software incorporated into Python is under different licenses. The licenses are listed with code falling under that license. PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 -------------------------------------------- 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and the Individual or Organization ("Licensee") accessing and otherwise using this software ("Python") in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, PSF hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python alone or in any derivative version, provided, however, that PSF's License Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved" are retained in Python alone or in any derivative version prepared by Licensee. 3. In the event Licensee prepares a derivative work that is based on or incorporates Python or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python. 4. PSF is making Python available to Licensee on an "AS IS" basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between PSF and Licensee. This License Agreement does not grant permission to use PSF trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By copying, installing or otherwise using Python, Licensee agrees to be bound by the terms and conditions of this License Agreement. BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 ------------------------------------------- BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the Individual or Organization ("Licensee") accessing and otherwise using this software in source or binary form and its associated documentation ("the Software"). 2. Subject to the terms and conditions of this BeOpen Python License Agreement, BeOpen hereby grants Licensee a non-exclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use the Software alone or in any derivative version, provided, however, that the BeOpen Python License is retained in the Software, alone or in any derivative version prepared by Licensee. 3. BeOpen is making the Software available to Licensee on an "AS IS" basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 5. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 6. This License Agreement shall be governed by and interpreted in all respects by the law of the State of California, excluding conflict of law provisions. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between BeOpen and Licensee. This License Agreement does not grant permission to use BeOpen trademarks or trade names in a trademark sense to endorse or promote products or services of Licensee, or any third party. As an exception, the "BeOpen Python" logos available at http://www.pythonlabs.com/logos.html may be used according to the permissions granted on that web page. 7. By copying, installing or otherwise using the software, Licensee agrees to be bound by the terms and conditions of this License Agreement. CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 --------------------------------------- 1. This LICENSE AGREEMENT is between the Corporation for National Research Initiatives, having an office at 1895 Preston White Drive, Reston, VA 20191 ("CNRI"), and the Individual or Organization ("Licensee") accessing and otherwise using Python 1.6.1 software in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, CNRI hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python 1.6.1 alone or in any derivative version, provided, however, that CNRI's License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) 1995-2001 Corporation for National Research Initiatives; All Rights Reserved" are retained in Python 1.6.1 alone or in any derivative version prepared by Licensee. Alternately, in lieu of CNRI's License Agreement, Licensee may substitute the following text (omitting the quotes): "Python 1.6.1 is made available subject to the terms and conditions in CNRI's License Agreement. This Agreement together with Python 1.6.1 may be located on the internet using the following unique, persistent identifier (known as a handle): 1895.22/1013. This Agreement may also be obtained from a proxy server on the internet using the following URL: http://hdl.handle.net/1895.22/1013". 3. In the event Licensee prepares a derivative work that is based on or incorporates Python 1.6.1 or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python 1.6.1. 4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. This License Agreement shall be governed by the federal intellectual property law of the United States, including without limitation the federal copyright law, and, to the extent such U.S. federal law does not apply, by the law of the Commonwealth of Virginia, excluding Virginia's conflict of law provisions. Notwithstanding the foregoing, with regard to derivative works based on Python 1.6.1 that incorporate non-separable material that was previously distributed under the GNU General Public License (GPL), the law of the Commonwealth of Virginia shall govern this License Agreement only as to issues arising under or with respect to Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between CNRI and Licensee. This License Agreement does not grant permission to use CNRI trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By clicking on the "ACCEPT" button where indicated, or by copying, installing or otherwise using Python 1.6.1, Licensee agrees to be bound by the terms and conditions of this License Agreement. ACCEPT CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 -------------------------------------------------- Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, The Netherlands. All rights reserved. Permission to use, copy, modify, and distribute this software and its documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appear in all copies and that both that copyright notice and this permission notice appear in supporting documentation, and that the name of Stichting Mathematisch Centrum or CWI not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission. STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION ---------------------------------------------------------------------- Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` -------------------------------------------------------------------------------- ## expandvars (1.1.2) **License:** MIT **License URL:** https://github.com/sayanarijit/expandvars/blob/master/LICENSE ``` MIT License Copyright (c) 2019 Arijit Basu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## extratools (0.8.2.1) **License:** MIT **License URL:** https://github.com/chuanconggao/extratools/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2016 Chuancong Gao Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## fastapi (0.120.4) **License:** MIT **License URL:** https://github.com/tiangolo/fastapi/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2018 Sebastián Ramírez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## fastuuid (0.14.0) **License:** BSD 3-Clause License **License URL:** https://github.com/thedrow/fastuuid/blob/master/LICENSE ``` Copyright (c) 2019, Omer Katz All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of fastuuid nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## ffmpeg (5.1.6) **License:** LGPL-2.1 **License URL:** https://git.ffmpeg.org/gitweb/ffmpeg.git/blob/HEAD:/LICENSE.md ``` GNU LESSER GENERAL PUBLIC LICENSE Version 2.1, February 1999 Copyright (C) 1991, 1999 Free Software Foundation, Inc. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] FFmpeg is free software licensed under the LGPL-2.1 or later. The full text of the LGPL-2.1 license is available at: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html and in the FFmpeg source distribution at LICENSE.md. ``` -------------------------------------------------------------------------------- ## filelock (3.20.0) **License:** Unlicense **License URL:** https://github.com/tox-dev/py-filelock/blob/main/LICENSE ``` MIT License Copyright (c) 2025 Bernát Gábor and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## filetype (1.2.0) **License:** MIT **License URL:** https://github.com/h2non/filetype.py/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2016 Tomás Aparicio Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## flask (3.1.3) **License:** BSD-3-Clause **License URL:** https://github.com/pallets/flask/blob/main/LICENSE.txt ``` Copyright 2010 Pallets Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## fonttools (4.61.0) **License:** MIT **License URL:** https://github.com/fonttools/fonttools/blob/main/LICENSE ``` MIT License Copyright (c) 2017 Just van Rossum Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## freetype-py (2.5.1) **License:** BSD License **License URL:** https://github.com/rougier/freetype-py/blob/master/LICENSE.txt ``` freetype-py is licensed under the terms of the new or revised BSD license, as follows: Copyright (c) 2011-2024, Nicolas P. Rougier All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. Neither the name of the freetype-py Development Team nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## frozenlist (1.8.0) **License:** Apache-2.0 **License URL:** https://github.com/aio-libs/frozenlist/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2013-2019 Nikolay Kim and Andrew Svetlov Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## fsspec (2025.10.0) **License:** BSD-3-Clause **License URL:** https://github.com/fsspec/filesystem_spec/blob/master/LICENSE ``` BSD 3-Clause License Copyright (c) 2018, Martin Durant All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## ftfy (6.3.1) **License:** Apache-2.0 **License URL:** https://github.com/rspeer/python-ftfy/blob/main/LICENSE.txt ``` Copyright 2023 Robyn Speer Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## googleapis-common-protos (1.72.0) **License:** Apache 2.0 **License URL:** https://github.com/googleapis/api-common-protos/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## greenlet (3.3.0) **License:** MIT AND Python-2.0 **License URL:** https://github.com/python-greenlet/greenlet/blob/master/LICENSE ``` The following files are derived from Stackless Python and are subject to the same license as Stackless Python: src/greenlet/slp_platformselect.h files in src/greenlet/platform/ directory See LICENSE.PSF and http://www.stackless.com/ for details. Unless otherwise noted, the files in greenlet have been released under the following MIT license: Copyright (c) Armin Rigo, Christian Tismer and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## grpcio (1.76.0) **License:** Apache License 2.0 **License URL:** https://github.com/grpc/grpc/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ----------------------------------------------------------- Following applies to: ./src/objective-c/!ProtoCompiler-gRPCCppPlugin.podspec ./src/objective-c/!ProtoCompiler-gRPCPlugin.podspec ./src/objective-c/!ProtoCompiler.podspec ./src/objective-c/BoringSSL-GRPC.podspec ./templates/src/objective-c/!ProtoCompiler-gRPCCppPlugin.podspec.inja ./templates/src/objective-c/!ProtoCompiler-gRPCPlugin.podspec.inja ./templates/src/objective-c/!ProtoCompiler.podspec.inja ./templates/src/objective-c/BoringSSL-GRPC.podspec.template BSD 3-Clause License Copyright 2016, Google Inc. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------- Following applies to: ./etc/roots.pem Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ``` -------------------------------------------------------------------------------- ## h11 (0.16.0) **License:** MIT **License URL:** https://github.com/python-hyper/h11/blob/master/LICENSE.txt ``` The MIT License (MIT) Copyright (c) 2016 Nathaniel J. Smith and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## hf-xet (1.2.1rc0) **License:** Apache Software License **License URL:** https://github.com/huggingface/xet-core/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## html5lib (1.1) **License:** MIT License **License URL:** https://github.com/html5lib/html5lib-python/blob/master/LICENSE ``` Copyright (c) 2006-2013 James Graham and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## httpcore (1.0.9) **License:** BSD-3-Clause **License URL:** https://github.com/encode/httpcore/blob/master/LICENSE.md ``` Copyright © 2020, [Encode OSS Ltd](https://www.encode.io/). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## httptools (0.7.1) **License:** MIT **License URL:** https://github.com/MagicStack/httptools/blob/master/LICENSE ``` The MIT License Copyright (c) 2015 MagicStack Inc. http://magic.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## httpx (0.28.1) **License:** BSD-3-Clause **License URL:** https://github.com/encode/httpx/blob/master/LICENSE.md ``` Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## httpx-sse (0.4.3) **License:** MIT **License URL:** https://github.com/florimondmanca/httpx-sse/blob/master/LICENSE ``` MIT License Copyright (c) 2022 Florimond Manca Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## huggingface-hub (0.36.0) **License:** Apache **License URL:** https://github.com/huggingface/huggingface_hub/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## idna (3.11) **License:** BSD-3-Clause **License URL:** https://github.com/kjd/idna/blob/master/LICENSE.md ``` BSD 3-Clause License Copyright (c) 2013-2025, Kim Davies and contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## importlib-metadata (8.7.0) **License:** Apache Software License **License URL:** https://pypi.org/project/importlib-metadata/ ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## itsdangerous (2.2.0) **License:** BSD-3-Clause **License URL:** https://github.com/pallets/itsdangerous/blob/main/LICENSE.txt ``` Copyright 2011 Pallets Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## jinja2 (3.1.6) **License:** BSD License **License URL:** https://github.com/pallets/jinja/blob/main/LICENSE.txt ``` Copyright 2007 Pallets Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## jiter (0.12.0) **License:** MIT License **License URL:** https://github.com/pydantic/jiter/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2022 to present Samuel Colvin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## jmespath (1.0.1) **License:** MIT **License URL:** https://github.com/jmespath/jmespath.py/blob/master/LICENSE.txt ``` MIT License Copyright (c) 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## joblib (1.5.2) **License:** BSD 3-Clause **License URL:** https://github.com/joblib/joblib/blob/main/LICENSE.txt ``` BSD 3-Clause License Copyright (c) 2008-2021, The joblib developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## jsonpatch (1.33) **License:** BSD 3-Clause **License URL:** https://github.com/stefankoegl/python-json-patch/blob/master/LICENSE ``` Copyright (c) 2011 Stefan Kögl Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## jsonpath-ng (1.7.0) **License:** Apache 2.0 **License URL:** https://github.com/h2non/jsonpath-ng/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## jsonpointer (3.0.0) **License:** Copyright (c) 2011 Stefan Kögl **License URL:** https://github.com/stefankoegl/python-json-pointer/blob/master/LICENSE.txt ``` Copyright (c) 2011 Stefan Kögl All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. The name of the author may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## jsonschema (4.25.1) **License:** MIT **License URL:** https://github.com/python-jsonschema/jsonschema/blob/main/COPYING ``` Copyright (c) 2013 Julian Berman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## jsonschema-specifications (2025.9.1) **License:** MIT **License URL:** https://github.com/python-jsonschema/jsonschema-specifications/blob/main/COPYING ``` Copyright (c) 2022 Julian Berman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## jupyterlab-plotly (6.0.1) **License:** MIT **License URL:** https://github.com/plotly/plotly.py/blob/main/LICENSE.txt ``` MIT License Copyright (c) 2016-2024 Plotly Technologies Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## kiwisolver (1.4.10rc0) **License:** BSD License **License URL:** https://github.com/nucleic/kiwi/blob/main/LICENSE ``` ========================= The Kiwi licensing terms ========================= Kiwi is licensed under the terms of the Modified BSD License (also known as New or Revised BSD), as follows: Copyright (c) 2013-2026, Nucleic Development Team All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. Neither the name of the Nucleic Development Team nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. About Kiwi ---------- Chris Colbert began the Kiwi project in December 2013 in an effort to create a blisteringly fast UI constraint solver. Chris is still the project lead. The Nucleic Development Team is the set of all contributors to the Nucleic project and its subprojects. The core team that coordinates development on GitHub can be found here: http://github.com/nucleic. The current team consists of: * Chris Colbert Our Copyright Policy -------------------- Nucleic uses a shared copyright model. Each contributor maintains copyright over their contributions to Nucleic. But, it is important to note that these contributions are typically only changes to the repositories. Thus, the Nucleic source code, in its entirety is not the copyright of any single person or institution. Instead, it is the collective copyright of the entire Nucleic Development Team. If individual contributors want to maintain a record of what changes/contributions they have specific copyright on, they should indicate their copyright in the commit message of the change, when they commit the change to one of the Nucleic repositories. With this in mind, the following banner should be used in any source code file to indicate the copyright and license terms: #------------------------------------------------------------------------------ # Copyright (c) 2013-2026, Nucleic Development Team. # # Distributed under the terms of the Modified BSD License. # # The full license is in the file LICENSE, distributed with this software. #------------------------------------------------------------------------------ ``` -------------------------------------------------------------------------------- ## langchain (0.3.27) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain/blob/master/LICENSE ``` MIT License Copyright (c) LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-aws (0.2.35) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain-aws/blob/main/LICENSE ``` MIT License Copyright (c) 2024 LangChain Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-classic (1.0.1) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain/blob/master/LICENSE ``` MIT License Copyright (c) LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-community (0.3.31) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain-community/blob/main/LICENSE ``` MIT License Copyright (c) 2024 LangChain Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-core (0.3.80) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain/blob/master/LICENSE ``` MIT License Copyright (c) LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-huggingface (1.2.0) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain/blob/master/LICENSE ``` MIT License Copyright (c) LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-litellm (0.2.3) **License:** MIT **License URL:** https://github.com/Akshay-Dongare/langchain-litellm/blob/main/LICENSE ``` MIT License Copyright (c) 2024 LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-milvus (0.2.1) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain-milvus/blob/main/LICENSE ``` MIT License Copyright (c) 2024 LangChain Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-nvidia-ai-endpoints (0.3.19) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain-nvidia/blob/main/LICENSE ``` MIT License Copyright (c) 2024 LangChain Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-openai (0.3.35) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain/blob/master/LICENSE ``` MIT License Copyright (c) LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-tavily (0.2.13) **License:** MIT **License URL:** https://github.com/tavily-ai/langchain-tavily/blob/main/LICENSE ``` MIT License Copyright (c) 2024 LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langchain-text-splitters (0.3.11) **License:** MIT **License URL:** https://github.com/langchain-ai/langchain/blob/master/LICENSE ``` MIT License Copyright (c) LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langgraph (0.6.11) **License:** MIT **License URL:** https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/LICENSE ``` MIT License Copyright (c) 2024 LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langgraph-checkpoint (3.0.1) **License:** MIT **License URL:** https://www.github.com/langchain-ai/langgraph/blob/main/LICENSE ``` MIT License Copyright (c) 2024 LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langgraph-prebuilt (0.6.5) **License:** MIT **License URL:** https://github.com/langchain-ai/langgraph/blob/main/libs/prebuilt/LICENSE ``` MIT License Copyright (c) 2024 LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langgraph-sdk (0.2.15) **License:** MIT **License URL:** https://github.com/langchain-ai/langgraph/blob/main/LICENSE ``` MIT License Copyright (c) 2024 LangChain, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## langsmith (0.4.58) **License:** MIT **License URL:** https://github.com/langchain-ai/langsmith-sdk/blob/main/LICENSE ``` MIT License Copyright (c) 2023 LangChain Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## litellm (1.80.5) **License:** MIT **License URL:** https://github.com/BerriAI/litellm/blob/main/LICENSE ``` Portions of this software are licensed as follows: * All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE". * Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below. --- MIT License Copyright (c) 2023 Berri AI Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## locket (1.0.0) **License:** BSD-2-Clause **License URL:** https://github.com/mwilliamson/locket.py/blob/master/LICENSE ``` Copyright (c) 2012, Michael Williamson All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## lxml (6.0.2) **License:** BSD-3-Clause **License URL:** https://github.com/lxml/lxml/blob/master/LICENSE.txt ``` BSD 3-Clause License Copyright (c) 2004 Infrae. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of Infrae nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL INFRAE OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## mako (1.3.10) **License:** Apache 2.0 **License URL:** https://github.com/aio-libs/aiohttp-mako/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2015-2018 Nikolay Novik and aio-libs team. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## markdown (3.1) **License:** BSD-3-Clause **License URL:** https://github.com/Python-Markdown/markdown/blob/master/LICENSE.md ``` BSD 3-Clause License Copyright 2007, 2008 The Python Markdown Project (v. 1.7 and later) Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) Copyright 2004 Manfred Stienstra (the original version) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## markdown-it-py (4.0.0) **License:** MIT License **License URL:** https://github.com/executablebooks/markdown-it-py/blob/master/LICENSE ``` MIT License Copyright (c) 2020 ExecutableBookProject Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## markupsafe (3.0.3) **License:** BSD-3-Clause **License URL:** https://github.com/pallets/markupsafe/blob/main/LICENSE.txt ``` Copyright 2010 Pallets Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## marshmallow (3.26.1) **License:** MIT License **License URL:** https://github.com/marshmallow-code/marshmallow/blob/master/LICENSE ``` Copyright Steven Loria and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## matplotlib (3.10.7) **License:** License agreement for matplotlib versions 1.3.0 and later **License URL:** https://github.com/matplotlib/matplotlib/blob/main/LICENSE/LICENSE ``` License agreement for matplotlib versions 1.3.0 and later ========================================================= 1. This LICENSE AGREEMENT is between the Matplotlib Development Team ("MDT"), and the Individual or Organization ("Licensee") accessing and otherwise using matplotlib software in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, MDT hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use matplotlib alone or in any derivative version, provided, however, that MDT's License Agreement and MDT's notice of copyright, i.e., "Copyright (c) 2012- Matplotlib Development Team; All Rights Reserved" are retained in matplotlib alone or in any derivative version prepared by Licensee. 3. In the event Licensee prepares a derivative work that is based on or incorporates matplotlib or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to matplotlib . 4. MDT is making matplotlib available to Licensee on an "AS IS" basis. MDT MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, MDT MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. MDT SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between MDT and Licensee. This License Agreement does not grant permission to use MDT trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By copying, installing or otherwise using matplotlib , Licensee agrees to be bound by the terms and conditions of this License Agreement. License agreement for matplotlib versions prior to 1.3.0 ======================================================== 1. This LICENSE AGREEMENT is between John D. Hunter ("JDH"), and the Individual or Organization ("Licensee") accessing and otherwise using matplotlib software in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, JDH hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use matplotlib alone or in any derivative version, provided, however, that JDH's License Agreement and JDH's notice of copyright, i.e., "Copyright (c) 2002-2011 John D. Hunter; All Rights Reserved" are retained in matplotlib alone or in any derivative version prepared by Licensee. 3. In the event Licensee prepares a derivative work that is based on or incorporates matplotlib or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to matplotlib. 4. JDH is making matplotlib available to Licensee on an "AS IS" basis. JDH MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, JDH MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. JDH SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between JDH and Licensee. This License Agreement does not grant permission to use JDH trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By copying, installing or otherwise using matplotlib, Licensee agrees to be bound by the terms and conditions of this License Agreement. ``` -------------------------------------------------------------------------------- ## mcp (1.23.3) **License:** MIT **License URL:** https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE ``` MIT License Copyright (c) 2024 Anthropic, PBC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## mdurl (0.1.2) **License:** Copyright (c) 2021 Taneli Hukkinen **License URL:** https://github.com/executablebooks/mdurl/blob/master/LICENSE ``` Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. Copyright (c) 2021 Taneli Hukkinen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- .parse() is based on Joyent's node.js `url` code: Copyright Joyent, Inc. and other Node contributors. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## mpmath (1.3.0) **License:** BSD **License URL:** https://github.com/fredrik-johansson/mpmath/blob/master/LICENSE ``` Copyright (c) 2005-2026 Fredrik Johansson and mpmath contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: a. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. b. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. c. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## msgpack (1.1.2) **License:** Apache-2.0 **License URL:** https://github.com/msgpack/msgpack-python/blob/main/COPYING ``` Copyright (C) 2008-2011 INADA Naoki Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## multidict (6.7.0) **License:** Apache License 2.0 **License URL:** https://github.com/aio-libs/multidict/blob/master/LICENSE ``` Copyright 2016 Andrew Svetlov and aio-libs contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## multiprocess (0.70.18) **License:** BSD-3-Clause **License URL:** https://github.com/uqfoundation/multiprocess/blob/master/LICENSE ``` Copyright (c) 2008-2016 California Institute of Technology. Copyright (c) 2016-2026 The Uncertainty Quantification Foundation. All rights reserved. This software forks the python package "multiprocessing". Licence and copyright information for multiprocessing can be found in "COPYING". This software is available subject to the conditions and terms laid out below. By downloading and using this software you are agreeing to the following conditions. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - Neither the names of the copyright holders nor the names of any of the contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## mypy-extensions (1.1.0) **License:** MIT **License URL:** https://github.com/python/mypy_extensions/blob/master/LICENSE ``` Mypy extensions are licensed under the terms of the MIT license, reproduced below. = = = = = The MIT License Copyright (c) 2016-2017 Jukka Lehtosalo and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. = = = = = ``` -------------------------------------------------------------------------------- ## narwhals (2.16.0) **License:** MIT **License URL:** https://github.com/narwhals-dev/narwhals/blob/main/LICENSE.md ``` MIT License Copyright (c) 2024, Marco Gorelli Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## nest-asyncio (1.6.0) **License:** BSD **License URL:** https://github.com/erdewit/nest_asyncio/blob/master/LICENSE ``` BSD 2-Clause License Copyright (c) 2018-2020, Ewald de Wit All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## nest-asyncio2 (1.7.1) **License:** BSD **License URL:** https://github.com/Chaoses-Ib/nest-asyncio2/blob/master/LICENSE ``` BSD 2-Clause License Copyright (c) 2018-2020, Ewald de Wit All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## networkx (3.6.1) **License:** BSD-3-Clause **License URL:** https://github.com/networkx/networkx/blob/main/LICENSE.txt ``` NetworkX is distributed with the 3-clause BSD license. :: Copyright (c) 2004-2025, NetworkX Developers Aric Hagberg Dan Schult Pieter Swart All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the NetworkX Developers nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## numpy (2.4.0rc1) **License:** Copyright (c) 2005-2025 Numpy Developpers **License URL:** https://github.com/numpy/numpy/blob/main/LICENSE.txt ``` Copyright (c) 2005-2025, NumPy Developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the NumPy Developers nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## open-clip-torch (3.2.0) **License:** MIT **License URL:** https://github.com/mlfoundations/open_clip/blob/main/LICENSE ``` Copyright (c) 2012-2021 Gabriel Ilharco, Mitchell Wortsman, Nicholas Carlini, Rohan Taori, Achal Dave, Vaishaal Shankar, John Miller, Hongseok Namkoong, Hannaneh Hajishirzi, Ali Farhadi, Ludwig Schmidt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## openai (2.9.0) **License:** Apache-2.0 **License URL:** https://github.com/openai/openai-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2026 OpenAI Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## opencv-python-headless (4.11.0.86) **License:** Apache 2.0 **License URL:** https://github.com/opencv/opencv-python/blob/master/LICENSE.txt ``` MIT License Copyright (c) Olli-Pekka Heinisuo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## openinference-instrumentation (0.1.42) **License:** Apache-2.0 **License URL:** https://github.com/Arize-ai/openinference/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## openinference-semantic-conventions (0.1.25) **License:** Apache-2.0 **License URL:** https://github.com/Arize-ai/openinference/blob/main/python/openinference-semantic-conventions/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright The OpenInference Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## openpyxl (3.1.5) **License:** MIT **License URL:** https://foss.heptapod.net/openpyxl/openpyxl/-/blob/branch/default/LICENSE.rst ``` This software is under the MIT Licence ====================================== Copyright (c) 2010 openpyxl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## opentelemetry-api (1.39.0) **License:** Apache-2.0 **License URL:** https://github.com/open-telemetry/opentelemetry-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## opentelemetry-exporter-otlp (1.39.0) **License:** Apache-2.0 **License URL:** https://github.com/open-telemetry/opentelemetry-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## opentelemetry-exporter-otlp-proto-common (1.39.0) **License:** Apache-2.0 **License URL:** https://github.com/open-telemetry/opentelemetry-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## opentelemetry-exporter-otlp-proto-grpc (1.39.0) **License:** Apache-2.0 **License URL:** https://github.com/open-telemetry/opentelemetry-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## opentelemetry-exporter-otlp-proto-http (1.39.0) **License:** Apache-2.0 **License URL:** https://github.com/open-telemetry/opentelemetry-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## opentelemetry-proto (1.39.0) **License:** Apache-2.0 **License URL:** https://github.com/open-telemetry/opentelemetry-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## opentelemetry-sdk (1.39.0) **License:** Apache-2.0 **License URL:** https://github.com/open-telemetry/opentelemetry-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## opentelemetry-semantic-conventions (0.60b0) **License:** Apache-2.0 **License URL:** https://github.com/open-telemetry/opentelemetry-python/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## optuna (4.4.0) **License:** MIT License **License URL:** https://github.com/optuna/optuna/blob/master/LICENSE ``` MIT License Copyright (c) 2018 Preferred Networks, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## orjson (3.11.5) **License:** Apache-2.0 OR MIT **License URL:** https://github.com/ijl/orjson/blob/master/LICENSE-MIT ``` Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## ormsgpack (1.12.0) **License:** Apache Software License **License URL:** https://github.com/aviramha/ormsgpack/blob/master/LICENSE-MIT ``` Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## oscrypto (1.3.0) **License:** MIT **License URL:** https://github.com/wbond/oscrypto/blob/master/LICENSE ``` Copyright (c) 2015-2022 Will Bond Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## packaging (25) **License:** Apache Software License **License URL:** https://github.com/pypa/packaging/blob/main/LICENSE ``` This software is made available under the terms of *either* of the licenses found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made under the terms of *both* these licenses. === LICENSE.APACHE === Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS === LICENSE.BSD === Copyright (c) Donald Stufft and individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## pandas (3.0.0rc0) **License:** BSD-3-Clause **License URL:** https://github.com/pandas-dev/pandas/blob/main/LICENSE ``` BSD 3-Clause License Copyright (c) 2008-2011, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team All rights reserved. Copyright (c) 2011-2026, Open source contributors. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## partd (1.4.2) **License:** BSD **License URL:** https://github.com/dask/partd/blob/main/LICENSE.txt ``` Copyright (c) 2015, Continuum Analytics, Inc. and contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. Neither the name of Continuum Analytics nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## pillow (12.0.0) **License:** MIT-CMU **License URL:** https://github.com/python-pillow/Pillow/blob/main/LICENSE ``` The Python Imaging Library (PIL) is Copyright © 1997-2011 by Secret Labs AB Copyright © 1995-2011 by Fredrik Lundh and contributors Pillow is the friendly PIL fork. It is Copyright © 2010 by Jeffrey A. Clark and contributors Like PIL, Pillow is licensed under the open source MIT-CMU License: By obtaining, using, and/or copying this software and/or its associated documentation, you agree that you have read, understood, and will comply with the following terms and conditions: Permission to use, copy, modify and distribute this software and its documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appears in all copies, and that both that copyright notice and this permission notice appear in supporting documentation, and that the name of Secret Labs AB or the author not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission. SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` -------------------------------------------------------------------------------- ## pip (25.3) **License:** MIT **License URL:** https://github.com/pypa/pip/blob/main/LICENSE.txt ``` Copyright (c) 2008-present The pip developers (see AUTHORS.txt file) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pkce (1.0.3) **License:** MIT **License URL:** https://github.com/RomeoDespres/pkce/blob/master/LICENSE ``` MIT License Copyright (c) 2020 Roméo Després Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pkginfo (1.12.1.2) **License:** MIT **License URL:** https://github.com/openpeeps/pkginfo/blob/master/LICENSE ``` MIT License Copyright (c) 2022 OpenPeep Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## platformdirs (4.5.1) **License:** MIT **License URL:** https://github.com/tox-dev/platformdirs/blob/main/LICENSE ``` MIT License Copyright (c) 2010-202x The platformdirs developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## plotly (6.5.2) **License:** MIT **License URL:** https://github.com/plotly/plotly.py/blob/main/LICENSE.txt ``` MIT License Copyright (c) 2016-2024 Plotly Technologies Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## ply (3.11) **License:** BSD **License URL:** https://www.dabeaz.com/ply/ ``` MIT License Copyright (C) 2001-2018 David M. Beazley (Dabeaz LLC) All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## prefixspan (0.5.2) **License:** MIT **License URL:** https://github.com/chuanconggao/PrefixSpan-py/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2016 Chuancong Gao Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## propcache (0.4.1) **License:** Apache-2.0 **License URL:** https://github.com/aio-libs/propcache/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## protobuf (6.33.2) **License:** Copyright 2008 Google Inc. All rights reserved **License URL:** https://github.com/protocolbuffers/protobuf/blob/main/LICENSE ``` Copyright 2008 Google Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Code generated by the Protocol Buffer compiler is owned by the owner of the input file used when generating it. This code is not standalone and requires a support library to be linked with it. This support library is itself covered by the above license. ``` -------------------------------------------------------------------------------- ## psutil (7.1.3) **License:** BSD-3-Clause **License URL:** https://github.com/giampaolo/psutil/blob/master/LICENSE ``` BSD 3-Clause License Copyright (c) 2009, Jay Loden, Dave Daeschler, Giampaolo Rodola All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the psutil authors nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## pyarrow (22.0.0) **License:** Apache Software License **License URL:** https://github.com/apache/arrow/blob/master/LICENSE.txt ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- src/arrow/util (some portions): Apache 2.0, and 3-clause BSD Some portions of this module are derived from code in the Chromium project, copyright (c) Google inc and (c) The Chromium Authors and licensed under the Apache 2.0 License or the under the 3-clause BSD license: Copyright (c) 2013 The Chromium Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- This project includes code from Daniel Lemire's FrameOfReference project. https://github.com/lemire/FrameOfReference/blob/6ccaf9e97160f9a3b299e23a8ef739e711ef0c71/src/bpacking.cpp https://github.com/lemire/FrameOfReference/blob/146948b6058a976bc7767262ad3a2ce201486b93/scripts/turbopacking64.py Copyright: 2013 Daniel Lemire Home page: http://lemire.me/en/ Project page: https://github.com/lemire/FrameOfReference License: Apache License Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- This project includes code from the TensorFlow project Copyright 2015 The TensorFlow Authors. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- This project includes code from the NumPy project. https://github.com/numpy/numpy/blob/e1f191c46f2eebd6cb892a4bfe14d9dd43a06c4e/numpy/core/src/multiarray/multiarraymodule.c#L2910 https://github.com/numpy/numpy/blob/68fd82271b9ea5a9e50d4e761061dfcca851382a/numpy/core/src/multiarray/datetime.c Copyright (c) 2005-2017, NumPy Developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the NumPy Developers nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- This project includes code from the Boost project Boost Software License - Version 1.0 - August 17th, 2003 Permission is hereby granted, free of charge, to any person or organization obtaining a copy of the software and accompanying documentation covered by this license (the "Software") to use, reproduce, display, distribute, execute, and transmit the Software, and to prepare derivative works of the Software, and to permit third-parties to whom the Software is furnished to do so, all subject to the following: The copyright notices in the Software and this entire statement, including the above license grant, this restriction and the following disclaimer, must be included in all copies of the Software, in whole or in part, and all derivative works of the Software, unless such copies or derivative works are solely in the form of machine-executable object code generated by a source language processor. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- This project includes code from the FlatBuffers project Copyright 2014 Google Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- This project includes code from the tslib project Copyright 2015 Microsoft Corporation. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- This project includes code from the jemalloc project https://github.com/jemalloc/jemalloc Copyright (C) 2002-2017 Jason Evans . All rights reserved. Copyright (C) 2007-2012 Mozilla Foundation. All rights reserved. Copyright (C) 2009-2017 Facebook, Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice(s), this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice(s), this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER(S) BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- This project includes code from the Go project, BSD 3-clause license + PATENTS weak patent termination clause (https://github.com/golang/go/blob/master/PATENTS). Copyright (c) 2009 The Go Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- This project includes code from the hs2client https://github.com/cloudera/hs2client Copyright 2016 Cloudera Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- The script r/configure has the following license (MIT) Copyright (c) 2017, Jeroen Ooms and Jim Hester Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- cpp/src/arrow/util/logging.cc, cpp/src/arrow/util/logging.h and cpp/src/arrow/util/logging-test.cc are adapted from Ray Project (https://github.com/ray-project/ray) (Apache 2.0). Copyright (c) 2016 Ray Project (https://github.com/ray-project/ray) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- The files cpp/src/arrow/vendored/datetime/date.h, cpp/src/arrow/vendored/datetime/tz.h, cpp/src/arrow/vendored/datetime/tz_private.h, cpp/src/arrow/vendored/datetime/ios.h, cpp/src/arrow/vendored/datetime/ios.mm, cpp/src/arrow/vendored/datetime/tz.cpp are adapted from Howard Hinnant's date library (https://github.com/HowardHinnant/date) It is licensed under MIT license. The MIT License (MIT) Copyright (c) 2015, 2016, 2017 Howard Hinnant Copyright (c) 2016 Adrian Colomitchi Copyright (c) 2017 Florian Dang Copyright (c) 2017 Paul Thompson Copyright (c) 2018 Tomasz Kamiński Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- The file cpp/src/arrow/util/utf8.h includes code adapted from the page https://bjoern.hoehrmann.de/utf-8/decoder/dfa/ with the following license (MIT) Copyright (c) 2008-2009 Bjoern Hoehrmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- The files in cpp/src/arrow/vendored/xxhash/ have the following license (BSD 2-Clause License) xxHash Library Copyright (c) 2012-2014, Yann Collet All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. You can contact the author at : - xxHash homepage: http://www.xxhash.com - xxHash source repository : https://github.com/Cyan4973/xxHash -------------------------------------------------------------------------------- The files in cpp/src/arrow/vendored/double-conversion/ have the following license (BSD 3-Clause License) Copyright 2006-2011, the V8 project authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- The files in cpp/src/arrow/vendored/uriparser/ have the following license (BSD 3-Clause License) uriparser - RFC 3986 URI parsing library Copyright (C) 2007, Weijia Song Copyright (C) 2007, Sebastian Pipping All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- The files under dev/tasks/conda-recipes have the following license BSD 3-clause license Copyright (c) 2015-2018, conda-forge All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- The files in cpp/src/arrow/vendored/utfcpp/ have the following license Copyright 2006-2018 Nemanja Trifunovic Permission is hereby granted, free of charge, to any person or organization obtaining a copy of the software and accompanying documentation covered by this license (the "Software") to use, reproduce, display, distribute, execute, and transmit the Software, and to prepare derivative works of the Software, and to permit third-parties to whom the Software is furnished to do so, all subject to the following: The copyright notices in the Software and this entire statement, including the above license grant, this restriction and the following disclaimer, must be included in all copies of the Software, in whole or in part, and all derivative works of the Software, unless such copies or derivative works are solely in the form of machine-executable object code generated by a source language processor. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- This project includes code from Apache Kudu. * cpp/cmake_modules/CompilerInfo.cmake is based on Kudu's cmake_modules/CompilerInfo.cmake Copyright: 2016 The Apache Software Foundation. Home page: https://kudu.apache.org/ License: http://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- This project includes code from Apache Impala (incubating), formerly Impala. The Impala code and rights were donated to the ASF as part of the Incubator process after the initial code imports into Apache Parquet. Copyright: 2012 Cloudera, Inc. Copyright: 2016 The Apache Software Foundation. Home page: http://impala.apache.org/ License: http://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- This project includes code from Apache Aurora. * dev/release/{release,changelog,release-candidate} are based on the scripts from Apache Aurora Copyright: 2016 The Apache Software Foundation. Home page: https://aurora.apache.org/ License: http://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- This project includes code from Snappy. * cpp/cmake_modules/{SnappyCMakeLists.txt,SnappyConfig.h} are based on code from Google's Snappy project. Copyright: 2009 Google Inc. All rights reserved. Homepage: https://github.com/google/snappy License: 3-clause BSD -------------------------------------------------------------------------------- This project includes code from the manylinux project. * python/manylinux1/scripts/{build_python.sh,python-tag-abi-tag.py, requirements.txt} are based on code from the manylinux project. Copyright: 2016 manylinux Homepage: https://github.com/pypa/manylinux License: The MIT License (MIT) -------------------------------------------------------------------------------- This project includes code from the cymove project: * python/pyarrow/includes/common.pxd includes code from the cymove project The MIT License (MIT) Copyright (c) 2019 Omer Ozarslan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- The projects includes code from the Ursabot project under the dev/archery directory. License: BSD 2-Clause Copyright 2019 RStudio, Inc. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- This project include code from mingw-w64. * cpp/src/arrow/util/cpu-info.cc has a polyfill for mingw-w64 < 5 Copyright (c) 2009 - 2013 by the mingw-w64 project Homepage: https://mingw-w64.org License: Zope Public License (ZPL) Version 2.1. --------------------------------------------------------------------------------- This project include code from Google's Asylo project. * cpp/src/arrow/result.h is based on status_or.h Copyright (c) Copyright 2017 Asylo authors Homepage: https://asylo.dev/ License: Apache 2.0 -------------------------------------------------------------------------------- This project includes code from Google's protobuf project * cpp/src/arrow/result.h ARROW_ASSIGN_OR_RAISE is based off ASSIGN_OR_RETURN * cpp/src/arrow/util/bit_stream_utils.h contains code from wire_format_lite.h Copyright 2008 Google Inc. All rights reserved. Homepage: https://developers.google.com/protocol-buffers/ License: Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Code generated by the Protocol Buffer compiler is owned by the owner of the input file used when generating it. This code is not standalone and requires a support library to be linked with it. This support library is itself covered by the above license. -------------------------------------------------------------------------------- 3rdparty dependency LLVM is statically linked in certain binary distributions. Additionally some sections of source code have been derived from sources in LLVM and have been clearly labeled as such. LLVM has the following license: ============================================================================== The LLVM Project is under the Apache License v2.0 with LLVM Exceptions: ============================================================================== Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ---- LLVM Exceptions to the Apache 2.0 License ---- As an exception, if, as a result of your compiling your source code, portions of this Software are embedded into an Object form of such source code, you may redistribute such embedded portions in such Object form without complying with the conditions of Sections 4(a), 4(b) and 4(d) of the License. In addition, if you combine or link compiled forms of this Software with software that is licensed under the GPLv2 ("Combined Software") and if a court of competent jurisdiction determines that the patent provision (Section 3), the indemnity provision (Section 9) or other Section of the License conflicts with the conditions of the GPLv2, you may retroactively and prospectively choose to deem waived or otherwise exclude such Section(s) of the License, but only in their entirety and only with respect to the Combined Software. ============================================================================== Software from third parties included in the LLVM Project: ============================================================================== The LLVM Project contains third party software which is under different license terms. All such code will be identified clearly using at least one of two mechanisms: 1) It will be in a separate directory tree with its own `LICENSE.txt` or `LICENSE` file at the top containing the specific license and restrictions which apply to that software, or 2) It will contain specific license and restriction terms at the top of every file. -------------------------------------------------------------------------------- 3rdparty dependency gRPC is statically linked in certain binary distributions, like the python wheels. gRPC has the following license: Copyright 2014 gRPC authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- 3rdparty dependency Apache Thrift is statically linked in certain binary distributions, like the python wheels. Apache Thrift has the following license: Apache Thrift Copyright (C) 2006 - 2019, The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- 3rdparty dependency Apache ORC is statically linked in certain binary distributions, like the python wheels. Apache ORC has the following license: Apache ORC Copyright 2013-2019 The Apache Software Foundation This product includes software developed by The Apache Software Foundation (http://www.apache.org/). This product includes software developed by Hewlett-Packard: (c) Copyright [2014-2015] Hewlett-Packard Development Company, L.P Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- 3rdparty dependency zstd is statically linked in certain binary distributions, like the python wheels. ZSTD has the following license: BSD License For Zstandard software Copyright (c) 2016-present, Facebook, Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name Facebook nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- 3rdparty dependency lz4 is statically linked in certain binary distributions, like the python wheels. lz4 has the following license: LZ4 Library Copyright (c) 2011-2016, Yann Collet All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- 3rdparty dependency Brotli is statically linked in certain binary distributions, like the python wheels. Brotli has the following license: Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- 3rdparty dependency rapidjson is statically linked in certain binary distributions, like the python wheels. rapidjson and its dependencies have the following licenses: Tencent is pleased to support the open source community by making RapidJSON available. Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights reserved. If you have downloaded a copy of the RapidJSON binary from Tencent, please note that the RapidJSON binary is licensed under the MIT License. If you have downloaded a copy of the RapidJSON source code from Tencent, please note that RapidJSON source code is licensed under the MIT License, except for the third-party components listed below which are subject to different license terms. Your integration of RapidJSON into your own projects may require compliance with the MIT License, as well as the other licenses applicable to the third-party components included within RapidJSON. To avoid the problematic JSON license in your own projects, it's sufficient to exclude the bin/jsonchecker/ directory, as it's the only code under the JSON license. A copy of the MIT License is included in this file. Other dependencies and licenses: Open Source Software Licensed Under the BSD License: -------------------------------------------------------------------- The msinttypes r29 Copyright (c) 2006-2013 Alexander Chemeris All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Terms of the MIT License: -------------------------------------------------------------------- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- 3rdparty dependency snappy is statically linked in certain binary distributions, like the python wheels. snappy has the following license: Copyright 2011, Google Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. === Some of the benchmark data in testdata/ is licensed differently: - fireworks.jpeg is Copyright 2013 Steinar H. Gunderson, and is licensed under the Creative Commons Attribution 3.0 license (CC-BY-3.0). See https://creativecommons.org/licenses/by/3.0/ for more information. - kppkn.gtb is taken from the Gaviota chess tablebase set, and is licensed under the MIT License. See https://sites.google.com/site/gaviotachessengine/Home/endgame-tablebases-1 for more information. - paper-100k.pdf is an excerpt (bytes 92160 to 194560) from the paper “Combinatorial Modeling of Chromatin Features Quantitatively Predicts DNA Replication Timing in _Drosophila_” by Federico Comoglio and Renato Paro, which is licensed under the CC-BY license. See http://www.ploscompbiol.org/static/license for more ifnormation. - alice29.txt, asyoulik.txt, plrabn12.txt and lcet10.txt are from Project Gutenberg. The first three have expired copyrights and are in the public domain; the latter does not have expired copyright, but is still in the public domain according to the license information (http://www.gutenberg.org/ebooks/53). -------------------------------------------------------------------------------- 3rdparty dependency gflags is statically linked in certain binary distributions, like the python wheels. gflags has the following license: Copyright (c) 2006, Google Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- 3rdparty dependency glog is statically linked in certain binary distributions, like the python wheels. glog has the following license: Copyright (c) 2008, Google Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. A function gettimeofday in utilities.cc is based on http://www.google.com/codesearch/p?hl=en#dR3YEbitojA/COPYING&q=GetSystemTimeAsFileTime%20license:bsd The license of this code is: Copyright (c) 2003-2008, Jouni Malinen and contributors All Rights Reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name(s) of the above-listed copyright holder(s) nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- 3rdparty dependency re2 is statically linked in certain binary distributions, like the python wheels. re2 has the following license: Copyright (c) 2009 The RE2 Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- 3rdparty dependency c-ares is statically linked in certain binary distributions, like the python wheels. c-ares has the following license: # c-ares license Copyright (c) 2007 - 2018, Daniel Stenberg with many contributors, see AUTHORS file. Copyright 1998 by the Massachusetts Institute of Technology. Permission to use, copy, modify, and distribute this software and its documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appear in all copies and that both that copyright notice and this permission notice appear in supporting documentation, and that the name of M.I.T. not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission. M.I.T. makes no representations about the suitability of this software for any purpose. It is provided "as is" without express or implied warranty. -------------------------------------------------------------------------------- 3rdparty dependency zlib is redistributed as a dynamically linked shared library in certain binary distributions, like the python wheels. In the future this will likely change to static linkage. zlib has the following license: zlib.h -- interface of the 'zlib' general purpose compression library version 1.2.11, January 15th, 2017 Copyright (C) 1995-2017 Jean-loup Gailly and Mark Adler This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 3. This notice may not be removed or altered from any source distribution. Jean-loup Gailly Mark Adler jloup@gzip.org madler@alumni.caltech.edu -------------------------------------------------------------------------------- 3rdparty dependency openssl is redistributed as a dynamically linked shared library in certain binary distributions, like the python wheels. openssl preceding version 3 has the following license: LICENSE ISSUES ============== The OpenSSL toolkit stays under a double license, i.e. both the conditions of the OpenSSL License and the original SSLeay license apply to the toolkit. See below for the actual license texts. OpenSSL License --------------- /* ==================================================================== * Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in * the documentation and/or other materials provided with the * distribution. * * 3. All advertising materials mentioning features or use of this * software must display the following acknowledgment: * "This product includes software developed by the OpenSSL Project * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" * * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to * endorse or promote products derived from this software without * prior written permission. For written permission, please contact * openssl-core@openssl.org. * * 5. Products derived from this software may not be called "OpenSSL" * nor may "OpenSSL" appear in their names without prior written * permission of the OpenSSL Project. * * 6. Redistributions of any form whatsoever must retain the following * acknowledgment: * "This product includes software developed by the OpenSSL Project * for use in the OpenSSL Toolkit (http://www.openssl.org/)" * * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED * OF THE POSSIBILITY OF SUCH DAMAGE. * ==================================================================== * * This product includes cryptographic software written by Eric Young * (eay@cryptsoft.com). This product includes software written by Tim * Hudson (tjh@cryptsoft.com). * */ Original SSLeay License ----------------------- /* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) * All rights reserved. * * This package is an SSL implementation written * by Eric Young (eay@cryptsoft.com). * The implementation was written so as to conform with Netscapes SSL. * * This library is free for commercial and non-commercial use as long as * the following conditions are aheared to. The following conditions * apply to all code found in this distribution, be it the RC4, RSA, * lhash, DES, etc., code; not just the SSL code. The SSL documentation * included with this distribution is covered by the same copyright terms * except that the holder is Tim Hudson (tjh@cryptsoft.com). * * Copyright remains Eric Young's, and as such any Copyright notices in * the code are not to be removed. * If this package is used in a product, Eric Young should be given attribution * as the author of the parts of the library used. * This can be in the form of a textual message at program startup or * in documentation (online or textual) provided with the package. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * 3. All advertising materials mentioning features or use of this software * must display the following acknowledgement: * "This product includes cryptographic software written by * Eric Young (eay@cryptsoft.com)" * The word 'cryptographic' can be left out if the rouines from the library * being used are not cryptographic related :-). * 4. If you include any Windows specific code (or a derivative thereof) from * the apps directory (application code) you must include an acknowledgement: * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" * * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF * SUCH DAMAGE. * * The licence and distribution terms for any publically available version or * derivative of this code cannot be changed. i.e. this code cannot simply be * copied and put under another distribution licence * [including the GNU Public Licence.] */ -------------------------------------------------------------------------------- This project includes code from the rtools-backports project. * ci/scripts/PKGBUILD and ci/scripts/r_windows_build.sh are based on code from the rtools-backports project. Copyright: Copyright (c) 2013 - 2019, Алексей and Jeroen Ooms. All rights reserved. Homepage: https://github.com/r-windows/rtools-backports License: 3-clause BSD -------------------------------------------------------------------------------- Some code from pandas has been adapted for the pyarrow codebase. pandas is available under the 3-clause BSD license, which follows: pandas license ============== Copyright (c) 2011-2012, Lambda Foundry, Inc. and PyData Development Team All rights reserved. Copyright (c) 2008-2011 AQR Capital Management, LLC All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- Some bits from DyND, in particular aspects of the build system, have been adapted from libdynd and dynd-python under the terms of the BSD 2-clause license The BSD 2-Clause License Copyright (C) 2011-12, Dynamic NDArray Developers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Dynamic NDArray Developers list: * Mark Wiebe * Continuum Analytics -------------------------------------------------------------------------------- Some source code from Ibis (https://github.com/cloudera/ibis) has been adapted for PyArrow. Ibis is released under the Apache License, Version 2.0. -------------------------------------------------------------------------------- dev/tasks/homebrew-formulae/apache-arrow.rb has the following license: BSD 2-Clause License Copyright (c) 2009-present, Homebrew contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ---------------------------------------------------------------------- cpp/src/arrow/vendored/base64.cpp has the following license ZLIB License Copyright (C) 2004-2017 René Nyffenegger This source code is provided 'as-is', without any express or implied warranty. In no event will the author be held liable for any damages arising from the use of this software. Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 1. The origin of this source code must not be misrepresented; you must not claim that you wrote the original source code. If you use this source code in a product, an acknowledgment in the product documentation would be appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original source code. 3. This notice may not be removed or altered from any source distribution. René Nyffenegger rene.nyffenegger@adp-gmbh.ch -------------------------------------------------------------------------------- This project includes code from Folly. * cpp/src/arrow/vendored/ProducerConsumerQueue.h is based on Folly's * folly/Portability.h * folly/lang/Align.h * folly/ProducerConsumerQueue.h Copyright: Copyright (c) Facebook, Inc. and its affiliates. Home page: https://github.com/facebook/folly License: http://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- The file cpp/src/arrow/vendored/musl/strptime.c has the following license Copyright © 2005-2020 Rich Felker, et al. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- The file cpp/cmake_modules/BuildUtils.cmake contains code from https://gist.github.com/cristianadam/ef920342939a89fae3e8a85ca9459b49 which is made available under the MIT license Copyright (c) 2019 Cristian Adam Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- The files in cpp/src/arrow/vendored/portable-snippets/ contain code from https://github.com/nemequ/portable-snippets and have the following copyright notice: Each source file contains a preamble explaining the license situation for that file, which takes priority over this file. With the exception of some code pulled in from other repositories (such as µnit, an MIT-licensed project which is used for testing), the code is public domain, released using the CC0 1.0 Universal dedication (*). (*) https://creativecommons.org/publicdomain/zero/1.0/legalcode -------------------------------------------------------------------------------- The files in cpp/src/arrow/vendored/fast_float/ contain code from https://github.com/lemire/fast_float which is made available under the Apache License 2.0. -------------------------------------------------------------------------------- The file python/pyarrow/vendored/docscrape.py contains code from https://github.com/numpy/numpydoc/ which is made available under the BSD 2-clause license. -------------------------------------------------------------------------------- The file python/pyarrow/vendored/version.py contains code from https://github.com/pypa/packaging/ which is made available under both the Apache license v2.0 and the BSD 2-clause license. -------------------------------------------------------------------------------- The files in cpp/src/arrow/vendored/pcg contain code from https://github.com/imneme/pcg-cpp and have the following copyright notice: Copyright 2014-2019 Melissa O'Neill , and the PCG Project contributors. SPDX-License-Identifier: (Apache-2.0 OR MIT) Licensed under the Apache License, Version 2.0 (provided in LICENSE-APACHE.txt and at http://www.apache.org/licenses/LICENSE-2.0) or under the MIT license (provided in LICENSE-MIT.txt and at http://opensource.org/licenses/MIT), at your option. This file may not be copied, modified, or distributed except according to those terms. Distributed on an "AS IS" BASIS, WITHOUT WARRANTY OF ANY KIND, either express or implied. See your chosen license for details. -------------------------------------------------------------------------------- r/R/dplyr-count-tally.R (some portions) Some portions of this file are derived from code from https://github.com/tidyverse/dplyr/ which is made available under the MIT license Copyright (c) 2013-2019 RStudio and others. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- The file src/arrow/util/io_util.cc contains code from the CPython project which is made available under the Python Software Foundation License Version 2. -------------------------------------------------------------------------------- 3rdparty dependency opentelemetry-cpp is statically linked in certain binary distributions. opentelemetry-cpp is made available under the Apache License 2.0. Copyright The OpenTelemetry Authors SPDX-License-Identifier: Apache-2.0 -------------------------------------------------------------------------------- ci/conan/ is based on code from Conan Package and Dependency Manager. Copyright (c) 2019 Conan.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- 3rdparty dependency UCX is redistributed as a dynamically linked shared library in certain binary distributions. UCX has the following license: Copyright (c) 2014-2015 UT-Battelle, LLC. All rights reserved. Copyright (C) 2014-2020 Mellanox Technologies Ltd. All rights reserved. Copyright (C) 2014-2015 The University of Houston System. All rights reserved. Copyright (C) 2015 The University of Tennessee and The University of Tennessee Research Foundation. All rights reserved. Copyright (C) 2016-2020 ARM Ltd. All rights reserved. Copyright (c) 2016 Los Alamos National Security, LLC. All rights reserved. Copyright (C) 2016-2020 Advanced Micro Devices, Inc. All rights reserved. Copyright (C) 2019 UChicago Argonne, LLC. All rights reserved. Copyright (c) 2018-2020 NVIDIA CORPORATION. All rights reserved. Copyright (C) 2020 Huawei Technologies Co., Ltd. All rights reserved. Copyright (C) 2016-2020 Stony Brook University. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- The file dev/tasks/r/github.packages.yml contains code from https://github.com/ursa-labs/arrow-r-nightly which is made available under the Apache License 2.0. -------------------------------------------------------------------------------- .github/actions/sync-nightlies/action.yml (some portions) Some portions of this file are derived from code from https://github.com/JoshPiper/rsync-docker which is made available under the MIT license Copyright (c) 2020 Joshua Piper Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- .github/actions/sync-nightlies/action.yml (some portions) Some portions of this file are derived from code from https://github.com/burnett01/rsync-deployments which is made available under the MIT license Copyright (c) 2019-2022 Contention Copyright (c) 2019-2022 Burnett01 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- java/vector/src/main/java/org/apache/arrow/vector/util/IntObjectHashMap.java java/vector/src/main/java/org/apache/arrow/vector/util/IntObjectMap.java These files are derived from code from Netty, which is made available under the Apache License 2.0. -------------------------------------------------------------------------------- cpp/src/arrow/util/math_internal.cc (some portions) Some portions of this file are derived from https://github.com/ankane/dist-rust/ which is made available under the MIT license The MIT License (MIT) Copyright (c) 2021-2023 Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- The files cpp/src/arrow/vendored/whereami/whereami.h, cpp/src/arrow/vendored/whereami/whereami.cc are adapted from Grégory Pakosz's whereami library (https://github.com/gpakosz/whereami) It is dual licensed under both the WTFPLv2 and MIT licenses. The WTFPLv2 License DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE Version 2, December 2004 Copyright (C) 2004 Sam Hocevar Everyone is permitted to copy and distribute verbatim or modified copies of this license document, and changing it is allowed as long as the name is changed. DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. You just DO WHAT THE FUCK YOU WANT TO. 1. Bla bla bla 2. Montesqieu et camembert, vive la France, zut alors! The MIT License (MIT) Copyright Gregory Pakosz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- The files in cpp/src/arrow/vendored/safeint/ contain code from https://github.com/dcleblanc/SafeInt and are made available under the MIT license. MIT License Copyright (c) 2018 Microsoft Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pycairo (1.29.0) **License:** LGPL-2.1-only OR MPL-1.1 **License URL:** https://github.com/pygobject/pycairo/tree/main?tab=LGPL-2.1-2-ov-file ``` PyCairo is free software. Every source file in the implementation of PyCairo is available to be redistributed and/or modified under the terms of either the GNU Lesser General Public License (LGPL) version 2.1 or the Mozilla Public License (MPL) version 1.1. Some files are available under more liberal terms, but we believe that in all cases, each file may be used under either the LGPL or the MPL. See the following files in this directory for the precise terms and conditions of either license: COPYING-LGPL-2.1 COPYING-MPL-1.1 Please see each file in the implementation for Copyright and licensing information. SPDX-License-Identifier: LGPL-2.1-only OR MPL-1.1 === COPYING-LGPL-2.1 === GNU LESSER GENERAL PUBLIC LICENSE Version 2.1, February 1999 Copyright (C) 1991, 1999 Free Software Foundation, Inc. 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. GNU LESSER GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) "Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) The modified work must itself be a software library. b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. (For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. This option is useful when you wish to copy part of the code of the Library into a program that is not a library. 4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. 5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. 10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Libraries If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA Also add information on how to contact you by electronic and paper mail. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the library `Frob' (a library for tweaking knobs) written by James Random Hacker. , 1 April 1990 Ty Coon, President of Vice That's all there is to it! ``` -------------------------------------------------------------------------------- ## pycparser (2.23) **License:** BSD-3-Clause **License URL:** https://github.com/eliben/pycparser/blob/main/LICENSE ``` pycparser -- A C parser in Python Copyright (c) 2008-2022, Eli Bendersky All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## pydantic (2.12.5) **License:** MIT **License URL:** https://github.com/pydantic/pydantic/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pydantic-core (2.41.5) **License:** MIT **License URL:** https://github.com/pydantic/pydantic-core/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2022 Samuel Colvin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pydantic-settings (2.12.0) **License:** MIT **License URL:** https://github.com/pydantic/pydantic-settings/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2022 Samuel Colvin and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pygments (2.19.2) **License:** BSD-2-Clause **License URL:** https://github.com/pygments/pygments/blob/master/LICENSE ``` Copyright (c) 2006-2022 by the respective authors (see AUTHORS file). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## pyhanko (0.32.0) **License:** MIT **License URL:** https://github.com/MatthiasValvekens/pyHanko/blob/master/LICENSE ``` MIT License Copyright (c) 2020-2023 Matthias Valvekens Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pyhanko-certvalidator (0.29.0) **License:** MIT **License URL:** https://github.com/MatthiasValvekens/pyHanko/blob/master/LICENSE ``` MIT License Copyright (c) 2020-2023 Matthias Valvekens Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pyjwt (2.10.1) **License:** MIT **License URL:** https://github.com/jpadilla/pyjwt/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2015-2022 José Padilla Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pymilvus (2.6.5) **License:** Apache Software License **License URL:** https://github.com/milvus-io/pymilvus/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2019 Zilliz Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## pyparsing (3.3.0b1) **License:** MIT **License URL:** https://github.com/pyparsing/pyparsing/blob/master/LICENSE ``` Copyright (c) 2003-2025 Paul McGuire Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pypdf (6.4.1) **License:** Copyright (c) 2006-2008,Mathieu Fenniak **License URL:** https://github.com/py-pdf/pypdf/blob/main/LICENSE ``` Copyright (c) 2006-2008, Mathieu Fenniak Some contributions copyright (c) 2007, Ashish Kulkarni Some contributions copyright (c) 2014, Steve Witham All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * The name of the author may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## python-bidi (0.6.7) **License:** GNU Library or Lesser General Public License (LGPL) **License URL:** https://github.com/MeirKriheli/python-bidi/blob/master/COPYING.LESSER ``` GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. ``` -------------------------------------------------------------------------------- ## python-dateutil (2.9.0.post0) **License:** Apache Software License or BSD License **License URL:** https://github.com/dateutil/dateutil/blob/master/LICENSE ``` Copyright 2017- Paul Ganssle Copyright 2017- dateutil contributors (see AUTHORS file) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. The above license applies to all contributions after 2017-12-01, as well as all contributions that have been re-licensed (see AUTHORS file for the list of contributors who have re-licensed their code). -------------------------------------------------------------------------------- dateutil - Extensions to the standard Python datetime module. Copyright (c) 2003-2011 - Gustavo Niemeyer Copyright (c) 2012-2014 - Tomi Pieviläinen Copyright (c) 2014-2016 - Yaron de Leeuw Copyright (c) 2015- - Paul Ganssle Copyright (c) 2015- - dateutil contributors (see AUTHORS file) All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The above BSD License Applies to all code, even that also covered by Apache 2.0. ``` -------------------------------------------------------------------------------- ## python-dotenv (1.1.1) **License:** BSD-3-Clause **License URL:** https://github.com/theskumar/python-dotenv/blob/main/LICENSE ``` Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dotenv-rw), 2013, Jacob Kaplan-Moss (django-dotenv) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - Neither the name of django-dotenv nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## python-multipart (0.0.20) **License:** Apache 2.0 **License URL:** https://github.com/Kludex/python-multipart/blob/master/LICENSE.txt ``` Copyright 2012, Andrew Dunham Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## pytz (2025.2) **License:** MIT **License URL:** https://pythonhosted.org/pytz/#license ``` Copyright (c) 2003-2019 Stuart Bishop Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## pyyaml (6.0.3) **License:** MIT **License URL:** https://github.com/yaml/pyyaml/blob/main/LICENSE ``` Copyright (c) 2017-2021 Ingy döt Net Copyright (c) 2006-2016 Kirill Simonov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## ragas (0.2.15) **License:** Apache 2.0 License **License URL:** https://github.com/vibrantlabsai/ragas/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [2023] [Vibrant Labs] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## referencing (0.37.0) **License:** MIT **License URL:** https://github.com/python-jsonschema/referencing?tab=MIT-1-ov-file#readme ``` Copyright (c) 2022 Julian Berman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## regex (2025.11.3) **License:** Apache-2.0 AND CNRI-Python **License URL:** https://github.com/mrabarnett/mrab-regex/blob/master/LICENSE.txt ``` This work was derived from the 're' module of CPython 2.6 and CPython 3.1, copyright (c) 1998-2001 by Secret Labs AB and licensed under CNRI's Python 1.6 license. All additions and alterations are licensed under the Apache 2.0 License. Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2020 Matthew Barnett Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## reportlab (4.4.5) **License:** BSD License **License URL:** https://pypi.org/project/reportlab/ ``` ##################################################################################### # # Copyright (c) 2000-2024, ReportLab Inc. # All rights reserved. # # Redistribution and use in source and binary forms, with or without modification, # are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # * Neither the name of the company nor the names of its contributors may be # used to endorse or promote products derived from this software without # specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. # IN NO EVENT SHALL THE OFFICERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER # IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF # SUCH DAMAGE. # ##################################################################################### ``` -------------------------------------------------------------------------------- ## requests (2.32.5) **License:** Apache-2.0 **License URL:** https://github.com/psf/requests/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. ``` -------------------------------------------------------------------------------- ## requests-toolbelt (1.0.0) **License:** Apache 2.0 **License URL:** https://github.com/requests/toolbelt/blob/master/LICENSE ``` Copyright 2014 Ian Cordasco, Cory Benfield Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## rich (13.9.4) **License:** MIT **License URL:** https://github.com/Textualize/rich/blob/master/LICENSE ``` Copyright (c) 2020 Will McGugan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## rlpycairo (0.4.0) **License:** BSD license **License URL:** https://pypi.org/project/rlPyCairo/ ``` BSD License Copyright (c) 2000-2022, ReportLab Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the ReportLab Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## rpds-py (0.30.0) **License:** MIT **License URL:** https://github.com/crate-py/rpds/blob/main/LICENSE ``` Copyright (c) 2023 Julian Berman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## s3transfer (0.14.0) **License:** Apache License 2.0 **License URL:** https://github.com/boto/s3transfer/blob/master/LICENSE.txt ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## safetensors (0.7.0) **License:** Apache Software License **License URL:** https://github.com/huggingface/safetensors/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## scikit-learn (1.8.0) **License:** BSD-3-Clause **License URL:** https://github.com/scikit-learn/scikit-learn/blob/main/COPYING ``` BSD 3-Clause License Copyright (c) 2007-2026 The scikit-learn developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## scipy (1.17.0rc1) **License:** BSD 3-Clause **License URL:** https://github.com/scipy/scipy/blob/main/LICENSE.txt ``` Copyright (c) 2001-2002 Enthought, Inc. 2003, SciPy Developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## sentence-transformers (5.1.2) **License:** Apache 2.0 **License URL:** https://github.com/huggingface/sentence-transformers/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2019 Nils Reimers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## shellingham (1.5.4) **License:** ISC **License URL:** https://github.com/sarugaku/shellingham/blob/master/LICENSE ``` Copyright (c) 2018, Tzu-ping Chung Permission to use, copy, modify, and distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` -------------------------------------------------------------------------------- ## six (1.17.0) **License:** MIT **License URL:** https://github.com/benjaminp/six/blob/main/LICENSE ``` Copyright (c) 2010-2024 Benjamin Peterson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## sniffio (1.3.1) **License:** MIT OR Apache-2.0 **License URL:** https://github.com/python-trio/sniffio/blob/master/LICENSE ``` This software is made available under the terms of *either* of the licenses found in LICENSE.APACHE2 or LICENSE.MIT. Contributions to are made under the terms of *both* these licenses. === LICENSE.MIT === The MIT License (MIT) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. === LICENSE.APACHE2 === Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## sortedcontainers (2.4.0) **License:** Apache 2.0 **License URL:** https://grantjenks.com/docs/sortedcontainers/#sorted-containers-license ``` Copyright 2014-2019 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## soupsieve (2.8) **License:** MIT **License URL:** https://github.com/facelessuser/soupsieve/blob/main/LICENSE.md ``` MIT License Copyright (c) 2018 - 2026 Isaac Muse Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## sqlalchemy (2.0.45) **License:** MIT **License URL:** https://github.com/sqlalchemy/sqlalchemy/blob/main/LICENSE ``` Copyright 2005-2026 SQLAlchemy authors and contributors . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## sse-starlette (3.0.3) **License:** BSD-3-Clause **License URL:** https://github.com/sysid/sse-starlette/blob/main/LICENSE ``` Copyright © 2020, [sysid](https://sysid.github.io/). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## starlette (0.49.3) **License:** BSD-3-Clause **License URL:** https://github.com/Kludex/starlette/blob/main/LICENSE.md ``` Copyright © 2018, [Encode OSS Ltd](https://www.encode.io/). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## svglib (1.6.0) **License:** LGPL-3.0-or-later **License URL:** https://github.com/deeplook/svglib/blob/main/LICENSE.txt ``` GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. ``` -------------------------------------------------------------------------------- ## sympy (1.14.0) **License:** BSD **License URL:** https://github.com/sympy/sympy/blob/master/LICENSE ``` Copyright (c) 2006-2023 SymPy Development Team All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: a. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. b. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. c. Neither the name of SymPy nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- Patches that were taken from the Diofant project (https://github.com/diofant/diofant) are licensed as: Copyright (c) 2006-2018 SymPy Development Team, 2013-2023 Sergey B Kirpichev All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: a. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. b. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. c. Neither the name of Diofant or SymPy nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- Submodules taken from the multipledispatch project (https://github.com/mrocklin/multipledispatch) are licensed as: Copyright (c) 2014 Matthew Rocklin All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: a. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. b. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. c. Neither the name of multipledispatch nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- The files under the directory sympy/parsing/autolev/tests/pydy-example-repo are directly copied from PyDy project and are licensed as: Copyright (c) 2009-2023, PyDy Authors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of this project nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PYDY AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- The files under the directory sympy/parsing/latex are directly copied from latex2sympy project and are licensed as: Copyright 2016, latex2sympy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## tabulate (0.9.0) **License:** MIT **License URL:** https://github.com/astanin/python-tabulate/blob/master/LICENSE ``` Copyright (c) 2011-2020 Sergey Astanin and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## tblib (3.2.2) **License:** BSD-2-Clause **License URL:** https://github.com/ionelmc/python-tblib/blob/master/LICENSE ``` BSD 2-Clause License Copyright (c) 2013-2025, Ionel Cristian Mărieș. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## tenacity (9.1.2) **License:** Apache 2.0 **License URL:** https://github.com/jd/tenacity/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## threadpoolctl (3.6.0) **License:** BSD 3-Clause **License URL:** https://github.com/joblib/threadpoolctl/blob/master/LICENSE ``` Copyright (c) 2019, threadpoolctl contributors Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## tiktoken (0.12.0) **License:** MIT **License URL:** https://github.com/openai/tiktoken/blob/main/LICENSE ``` MIT License Copyright (c) 2022 OpenAI, Shantanu Jain Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## timm (1.0.24) **License:** Apache-2.0 **License URL:** https://github.com/huggingface/pytorch-image-models/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2019 Ross Wightman Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## tinycss2 (1.5.1) **License:** BSD License **License URL:** https://github.com/Kozea/tinycss2/blob/main/LICENSE ``` BSD 3-Clause License Copyright (c) 2013-2020, Simon Sapin and contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## tokenizers (0.22.2rc0) **License:** Apache Software License **License URL:** https://github.com/huggingface/tokenizers/blob/main/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## toolz (1.1.0) **License:** Copyright (c) 2013 Matthew Rocklin **License URL:** https://github.com/pytoolz/toolz/blob/master/LICENSE.txt ``` Copyright (c) 2013 Matthew Rocklin All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: a. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. b. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. c. Neither the name of toolz nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## torch (2.9.1+cpu) **License:** BSD-3-Clause **License URL:** https://github.com/pytorch/pytorch/blob/master/LICENSE ``` From PyTorch: Copyright (c) 2016- Facebook, Inc (Adam Paszke) Copyright (c) 2014- Facebook, Inc (Soumith Chintala) Copyright (c) 2011-2014 Idiap Research Institute (Ronan Collobert) Copyright (c) 2012-2014 Deepmind Technologies (Koray Kavukcuoglu) Copyright (c) 2011-2012 NEC Laboratories America (Koray Kavukcuoglu) Copyright (c) 2011-2013 NYU (Clement Farabet) Copyright (c) 2006-2010 NEC Laboratories America (Ronan Collobert, Leon Bottou, Iain Melvin, Jason Weston) Copyright (c) 2006 Idiap Research Institute (Samy Bengio) Copyright (c) 2001-2004 Idiap Research Institute (Ronan Collobert, Samy Bengio, Johnny Mariethoz) From Caffe2: Copyright (c) 2016-present, Facebook Inc. All rights reserved. All contributions by Facebook: Copyright (c) 2016 Facebook Inc. All contributions by Google: Copyright (c) 2015 Google Inc. All rights reserved. All contributions by Yangqing Jia: Copyright (c) 2015 Yangqing Jia All rights reserved. All contributions by Kakao Brain: Copyright 2019-2020 Kakao Brain All contributions by Cruise LLC: Copyright (c) 2022 Cruise LLC. All rights reserved. All contributions by Tri Dao: Copyright (c) 2024 Tri Dao. All rights reserved. All contributions by Arm: Copyright (c) 2021, 2023-2025 Arm Limited and/or its affiliates All contributions from Caffe: Copyright(c) 2013, 2014, 2015, the respective contributors All rights reserved. All other contributions: Copyright(c) 2015, 2016 the respective contributors All rights reserved. Caffe2 uses a copyright model similar to Caffe: each contributor holds copyright over their contributions to Caffe2. The project versioning records all such contribution and copyright details. If a contributor wants to further mark their specific copyright on a particular contribution, they should indicate their copyright solely in the commit message of the change when it is committed. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the names of Facebook, Deepmind Technologies, NYU, NEC Laboratories America and IDIAP Research Institute nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## torchvision (0.25.0+cpu) **License:** BSD **License URL:** https://github.com/pytorch/vision/blob/main/LICENSE ``` BSD 3-Clause License Copyright (c) Soumith Chintala 2016, All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## tornado (6.5.2) **License:** Apache-2.0 **License URL:** https://github.com/tornadoweb/tornado/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## tqdm (4.67.1) **License:** MPL-2.0 AND MIT **License URL:** https://github.com/tqdm/tqdm/blob/master/LICENCE ``` `tqdm` is a product of collaborative work. Unless otherwise stated, all authors (see commit logs) retain copyright for their respective work, and release the work under the MIT licence (text below). Exceptions or notable authors are listed below in reverse chronological order: * files: * MPL-2.0 2015-2026 (c) Casper da Costa-Luis [casperdcl](https://github.com/casperdcl). * files: tqdm/_tqdm.py MIT 2016 (c) [PR #96] on behalf of Google Inc. * files: tqdm/_tqdm.py README.rst .gitignore MIT 2013 (c) Noam Yorav-Raphael, original author. [PR #96]: https://github.com/tqdm/tqdm/pull/96 Mozilla Public Licence (MPL) v. 2.0 - Exhibit A ----------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this project, You can obtain one at https://mozilla.org/MPL/2.0/. MIT License (MIT) ----------------- Copyright (c) 2013 noamraph Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## transformers (4.57.3) **License:** Apache 2.0 License **License URL:** https://github.com/huggingface/transformers/blob/main/LICENSE ``` Copyright 2018- The Hugging Face team. All rights reserved. Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## typer-slim (0.21.1) **License:** MIT **License URL:** https://github.com/fastapi/typer/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2019 Sebastián Ramírez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## typing-extensions (4.15.0) **License:** PSF-2.0 **License URL:** https://github.com/python/typing_extensions/blob/main/LICENSE ``` A. HISTORY OF THE SOFTWARE ========================== Python was created in the early 1990s by Guido van Rossum at Stichting Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands as a successor of a language called ABC. Guido remains Python's principal author, although it includes many contributions from others. In 1995, Guido continued his work on Python at the Corporation for National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) in Reston, Virginia where he released several versions of the software. In May 2000, Guido and the Python core development team moved to BeOpen.com to form the BeOpen PythonLabs team. In October of the same year, the PythonLabs team moved to Digital Creations, which became Zope Corporation. In 2001, the Python Software Foundation (PSF, see https://www.python.org/psf/) was formed, a non-profit organization created specifically to own Python-related Intellectual Property. Zope Corporation was a sponsoring member of the PSF. All Python releases are Open Source (see https://opensource.org for the Open Source Definition). Historically, most, but not all, Python releases have also been GPL-compatible; the table below summarizes the various releases. Release Derived Year Owner GPL- from compatible? (1) 0.9.0 thru 1.2 1991-1995 CWI yes 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes 1.6 1.5.2 2000 CNRI no 2.0 1.6 2000 BeOpen.com no 1.6.1 1.6 2001 CNRI yes (2) 2.1 2.0+1.6.1 2001 PSF no 2.0.1 2.0+1.6.1 2001 PSF yes 2.1.1 2.1+2.0.1 2001 PSF yes 2.1.2 2.1.1 2002 PSF yes 2.1.3 2.1.2 2002 PSF yes 2.2 and above 2.1.1 2001-now PSF yes Footnotes: (1) GPL-compatible doesn't mean that we're distributing Python under the GPL. All Python licenses, unlike the GPL, let you distribute a modified version without making your changes open source. The GPL-compatible licenses make it possible to combine Python with other software that is released under the GPL; the others don't. (2) According to Richard Stallman, 1.6.1 is not GPL-compatible, because its license has a choice of law clause. According to CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 is "not incompatible" with the GPL. Thanks to the many outside volunteers who have worked under Guido's direction to make these releases possible. B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON =============================================================== Python software and documentation are licensed under the Python Software Foundation License Version 2. Starting with Python 3.8.6, examples, recipes, and other code in the documentation are dual licensed under the PSF License Version 2 and the Zero-Clause BSD license. Some software incorporated into Python is under different licenses. The licenses are listed with code falling under that license. PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 -------------------------------------------- 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and the Individual or Organization ("Licensee") accessing and otherwise using this software ("Python") in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, PSF hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python alone or in any derivative version, provided, however, that PSF's License Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; All Rights Reserved" are retained in Python alone or in any derivative version prepared by Licensee. 3. In the event Licensee prepares a derivative work that is based on or incorporates Python or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python. 4. PSF is making Python available to Licensee on an "AS IS" basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between PSF and Licensee. This License Agreement does not grant permission to use PSF trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By copying, installing or otherwise using Python, Licensee agrees to be bound by the terms and conditions of this License Agreement. BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 ------------------------------------------- BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the Individual or Organization ("Licensee") accessing and otherwise using this software in source or binary form and its associated documentation ("the Software"). 2. Subject to the terms and conditions of this BeOpen Python License Agreement, BeOpen hereby grants Licensee a non-exclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use the Software alone or in any derivative version, provided, however, that the BeOpen Python License is retained in the Software, alone or in any derivative version prepared by Licensee. 3. BeOpen is making the Software available to Licensee on an "AS IS" basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 5. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 6. This License Agreement shall be governed by and interpreted in all respects by the law of the State of California, excluding conflict of law provisions. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between BeOpen and Licensee. This License Agreement does not grant permission to use BeOpen trademarks or trade names in a trademark sense to endorse or promote products or services of Licensee, or any third party. As an exception, the "BeOpen Python" logos available at http://www.pythonlabs.com/logos.html may be used according to the permissions granted on that web page. 7. By copying, installing or otherwise using the software, Licensee agrees to be bound by the terms and conditions of this License Agreement. CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 --------------------------------------- 1. This LICENSE AGREEMENT is between the Corporation for National Research Initiatives, having an office at 1895 Preston White Drive, Reston, VA 20191 ("CNRI"), and the Individual or Organization ("Licensee") accessing and otherwise using Python 1.6.1 software in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, CNRI hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python 1.6.1 alone or in any derivative version, provided, however, that CNRI's License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) 1995-2001 Corporation for National Research Initiatives; All Rights Reserved" are retained in Python 1.6.1 alone or in any derivative version prepared by Licensee. Alternately, in lieu of CNRI's License Agreement, Licensee may substitute the following text (omitting the quotes): "Python 1.6.1 is made available subject to the terms and conditions in CNRI's License Agreement. This Agreement together with Python 1.6.1 may be located on the internet using the following unique, persistent identifier (known as a handle): 1895.22/1013. This Agreement may also be obtained from a proxy server on the internet using the following URL: http://hdl.handle.net/1895.22/1013". 3. In the event Licensee prepares a derivative work that is based on or incorporates Python 1.6.1 or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python 1.6.1. 4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. This License Agreement shall be governed by the federal intellectual property law of the United States, including without limitation the federal copyright law, and, to the extent such U.S. federal law does not apply, by the law of the Commonwealth of Virginia, excluding Virginia's conflict of law provisions. Notwithstanding the foregoing, with regard to derivative works based on Python 1.6.1 that incorporate non-separable material that was previously distributed under the GNU General Public License (GPL), the law of the Commonwealth of Virginia shall govern this License Agreement only as to issues arising under or with respect to Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between CNRI and Licensee. This License Agreement does not grant permission to use CNRI trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By clicking on the "ACCEPT" button where indicated, or by copying, installing or otherwise using Python 1.6.1, Licensee agrees to be bound by the terms and conditions of this License Agreement. ACCEPT CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 -------------------------------------------------- Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, The Netherlands. All rights reserved. Permission to use, copy, modify, and distribute this software and its documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appear in all copies and that both that copyright notice and this permission notice appear in supporting documentation, and that the name of Stichting Mathematisch Centrum or CWI not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission. STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION ---------------------------------------------------------------------- Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` -------------------------------------------------------------------------------- ## typing-inspect (0.9.0) **License:** MIT **License URL:** https://github.com/ilevkivskyi/typing_inspect/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2017-2019 Ivan Levkivskyi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## typing-inspection (0.4.2) **License:** MIT **License URL:** https://github.com/pydantic/typing-inspection/blob/main/LICENSE ``` MIT License Copyright (c) Pydantic Services Inc. 2025 to present Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## tzdata (2025.2) **License:** Apache-2.0 **License URL:** https://github.com/python/tzdata/blob/master/LICENSE ``` Apache Software License 2.0 Copyright (c) 2020, Paul Ganssle (Google) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## tzlocal (5.3.1) **License:** MIT **License URL:** https://github.com/regebro/tzlocal/blob/master/LICENSE.txt ``` Copyright 2011-2017 Lennart Regebro Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## uritools (5.0.0) **License:** MIT **License URL:** https://github.com/tkem/uritools/blob/master/LICENSE ``` The MIT License (MIT) Copyright (c) 2014-2025 Thomas Kemmer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## urllib3 (2.6.1) **License:** MIT **License URL:** https://github.com/urllib3/urllib3/blob/main/LICENSE.txt ``` MIT License Copyright (c) 2008-2020 Andrey Petrov and contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## uuid-utils (0.12.0) **License:** BSD License **License URL:** https://github.com/aminalaee/uuid-utils/blob/main/LICENSE.md ``` Copyright © 2023, Amin Alaee. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## uvicorn (0.35.0) **License:** BSD-3-Clause **License URL:** https://github.com/Kludex/uvicorn/blob/main/LICENSE.md ``` Copyright © 2017-present, [Encode OSS Ltd](https://www.encode.io/). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## uvloop (0.22.1) **License:** MIT License **License URL:** https://github.com/MagicStack/uvloop/blob/master/LICENSE-MIT ``` The MIT License Copyright (C) 2016-present the uvloop authors and contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## watchfiles (1.1.1) **License:** MIT **License URL:** https://github.com/samuelcolvin/watchfiles/blob/main/LICENSE ``` The MIT License (MIT) Copyright (c) 2017 to present Samuel Colvin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## wcwidth (0.6.0) **License:** MIT **License URL:** N/A ``` The MIT License (MIT) Copyright (c) 2014 Jeff Quast Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Markus Kuhn -- 2007-05-26 (Unicode 5.0) Permission to use, copy, modify, and distribute this software for any purpose and without fee is hereby granted. The author disclaims all warranties with regard to this software. ``` -------------------------------------------------------------------------------- ## webencodings (0.5.1) **License:** BSD **License URL:** https://github.com/SimonSapin/python-webencodings/blob/master/LICENSE ``` Copyright (c) 2012 by Simon Sapin. Some rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * The names of the contributors may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## websockets (15.0.1) **License:** BSD-3-Clause **License URL:** https://github.com/python-websockets/websockets/blob/main/LICENSE ``` Copyright (c) Aymeric Augustin and contributors Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## werkzeug (3.1.6) **License:** BSD-3-Clause **License URL:** https://github.com/pallets/werkzeug/blob/main/LICENSE.txt ``` Copyright 2007 Pallets Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## wikipedia (1.4.0) **License:** MIT **License URL:** https://github.com/goldsmith/Wikipedia/blob/master/LICENSE ``` Copyright 2013 Jonathan Goldsmith Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## wrapt (1.17.3) **License:** BSD 2-Clause **License URL:** https://github.com/GrahamDumpleton/wrapt/blob/master/LICENSE ``` Copyright (c) 2013-2026, Graham Dumpleton All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## xhtml2pdf (0.2.17) **License:** Apache 2.0 **License URL:** https://github.com/xhtml2pdf/xhtml2pdf/blob/master/LICENSE.txt ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## xxhash (3.6.0) **License:** BSD **License URL:** https://github.com/ifduyue/python-xxhash/blob/master/LICENSE ``` Copyright (c) 2014-2024, Yue Du All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## yarl (1.22.0) **License:** Apache-2.0 **License URL:** https://github.com/aio-libs/yarl/blob/master/LICENSE ``` Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -------------------------------------------------------------------------------- ## zict (3.0.0) **License:** BSD **License URL:** https://github.com/dask/zict?tab=BSD-3-Clause-1-ov-file#readme ``` Copyright (c) 2016 Matthew Rocklin All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: a. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. b. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. c. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ## zipp (3.23.0) **License:** MIT **License URL:** https://pypi.org/project/zipp/ ``` MIT License Copyright (c) 2025 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -------------------------------------------------------------------------------- ## zstandard (0.25.0) **License:** BSD-3-Clause **License URL:** https://github.com/indygreg/python-zstandard/blob/main/LICENSE ``` Copyright (c) 2016, Gregory Szorc All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -------------------------------------------------------------------------------- ================================================ FILE: agent/LICENSE.md ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to the Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by the Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding any notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own license statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: agent/README.md ================================================ # NVIDIA VSS Agent AI-powered video search, summarization, and incident analysis agent built on [NVIDIA AIQ Toolkit](https://docs.nvidia.com/nemo/agent-toolkit/latest/index.html). For deployment instructions (Docker Compose, Helm, cloud), refer to the [repository root](../README.md) and [`deployments/`](../deployments/). ## Overview VSS Agent provides composable tools and agents for video understanding: - **Video Search & Summarization** — natural language search across video streams - **Incident Analysis** — automated investigation and report generation - **Video Understanding** — frame-level analysis with Vision Language Models - **Video Analytics** — metadata, behavior, and event queries ## Project Structure | Path | Description | |------|-------------| | `src/vss_agents/` | Core package: tools, agents, APIs, embeddings, evaluators | | `tests/unit_test/` | Unit tests (mirrors source tree) | | `stubs/` | Mypy type stubs for third-party libraries | | `docker/` | Dockerfile and build scripts | | `3rdparty/` | Third-party source (FFmpeg, included for LGPL compliance) | ## Prerequisites - Python >= 3.13 - [uv](https://docs.astral.sh/uv/) package manager ## Installation Install system libraries required for PDF generation: ```bash sudo apt-get install libcairo2-dev pkg-config python3-dev ``` Install `uv` and create the virtual environment. If Python 3.13 is not present on the system, `uv` downloads it automatically: ```bash curl -LsSf https://astral.sh/uv/install.sh | sh uv venv --python 3.13 uv sync source .venv/bin/activate ``` ### Docker ```bash cd .. # Be at repo root level docker buildx build --platform linux/amd64 -f agent/docker/Dockerfile -t vss-agent:latest --load . ``` ## Quick Start The instructions below use the **dev-profile-base** profile as an example. The same pattern applies to other profiles (search, alerts, LVS) — substitute the corresponding `.env` and `config.yml` from [`deployments/developer-workflow/`](../deployments/developer-workflow/). See [Configuration](#configuration) for the full list of profiles. ### 1. Set Environment Variables Create a `.env_file` that points to the profile's `.env` so the agent auto-loads environment variables on startup (one-time per profile): ```bash echo "../deployments/developer-workflow/dev-profile-base/.env" > .env_file ``` Then source the same `.env` in your shell and override the placeholders. `set -a` auto-exports every variable so child processes inherit them. Because `HOST_IP` and `LLM/VLM_BASE_URL` are set **after** sourcing, every variable the `.env` derived from them (VST URLs, Phoenix, reports URL, …) must be re-evaluated — that is what the remaining lines do. ```bash set -a source ../deployments/developer-workflow/dev-profile-base/.env HOST_IP= # placeholder in .env LLM_BASE_URL=http://${HOST_IP}:${LLM_PORT} # empty in .env VLM_BASE_URL=http://${HOST_IP}:${VLM_PORT} # empty in .env EXTERNAL_IP=${HOST_IP} # not in .env, used by config INTERNAL_IP=${HOST_IP} # not in .env, used by config # re-evaluate vars that were derived from the placeholder HOST_IP / empty URLs EXTERNALLY_ACCESSIBLE_IP=${HOST_IP} VST_INTERNAL_URL=http://${HOST_IP}:${VST_PORT} VST_EXTERNAL_URL=http://${EXTERNALLY_ACCESSIBLE_IP}:${VST_PORT} VSS_AGENT_REPORTS_BASE_URL=http://${EXTERNALLY_ACCESSIBLE_IP}:${VSS_AGENT_PORT}/static/ PHOENIX_ENDPOINT=http://${HOST_IP}:6006 EVAL_LLM_JUDGE_BASE_URL=${LLM_BASE_URL} set +a ``` ### 2. Start the Agent ```bash nat serve \ --config_file ../deployments/developer-workflow/dev-profile-base/vss-agent/configs/config.yml \ --host 0.0.0.0 --port 8000 ``` On success you will see: ``` INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) ``` ### 3. Verify ```bash curl http://localhost:8000/health ``` ## Usage Start the agent server: ```bash nat serve --config_file .yaml --host 0.0.0.0 --port 8000 ``` ### Configuration Agent behavior is defined in YAML config files with four top-level sections: | Section | Purpose | |---------|---------| | `general` | Front-end type (FastAPI), CORS, telemetry, object stores | | `functions` | Tool and sub-agent definitions (video understanding, VST, reports, …) | | `llms` | LLM / VLM connection profiles (NIM, OpenAI, vLLM, …) | | `workflow` | Orchestration — which LLM drives the agent, which tools are available, system prompt | Config values support `${ENV_VAR}` substitution with optional defaults (`${VAR:-default}`). Ready-to-use configurations are provided under [`deployments/developer-workflow/`](../deployments/developer-workflow/): | Profile | Path | Description | |---------|------|-------------| | Base | [`dev-profile-base/.../config.yml`](../deployments/developer-workflow/dev-profile-base/vss-agent/configs/config.yml) | Video understanding and report generation | | Search | [`dev-profile-search/.../config.yml`](../deployments/developer-workflow/dev-profile-search/vss-agent/configs/config.yml) | Search and RAG workflow | | LVS | [`dev-profile-lvs/.../config.yml`](../deployments/developer-workflow/dev-profile-lvs/vss-agent/configs/config.yml) | LVS video understanding | | Alerts | [`dev-profile-alerts/.../config.yml`](../deployments/developer-workflow/dev-profile-alerts/vss-agent/configs/config.yml) | Incident analysis and alerting | Each profile has a companion `.env` file in the same directory with all deployment variables pre-configured. ### Environment Variables The table below lists every variable referenced by the agent config files. Variables marked **required** must be set before `nat serve`; the rest have sensible defaults or are only needed for specific features. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `HOST_IP` | yes | — | IP of the host running backing services | | `EXTERNAL_IP` | yes | — | Externally reachable IP (usually same as `HOST_IP`) | | `INTERNAL_IP` | yes | — | Internal IP (usually same as `HOST_IP`) | | `LLM_BASE_URL` | yes | — | LLM endpoint (e.g. `http://HOST:30081`) | | `VLM_BASE_URL` | yes | — | VLM endpoint (e.g. `http://HOST:30082`) | | `LLM_NAME` | yes | — | LLM model name (e.g. `nvidia/nvidia-nemotron-nano-9b-v2`) | | `VLM_NAME` | yes | — | VLM model name (e.g. `nvidia/cosmos-reason2-8b`) | | `LLM_MODEL_TYPE` | no | `nim` | LLM backend type: `nim`, `openai` | | `VLM_MODEL_TYPE` | no | `nim` | VLM backend type: `nim`, `openai`, `vllm`, `rtvi` | | `VLM_MODE` | no | `local_shared` | VLM deployment mode: `local_shared`, `local`, `remote` | | `VST_INTERNAL_URL` | yes | — | VST internal URL (e.g. `http://HOST:30888`) | | `VST_EXTERNAL_URL` | yes | — | VST external URL (e.g. `http://HOST:30888`) | | `VSS_AGENT_PORT` | no | `8000` | Agent HTTP port | | `VSS_AGENT_OBJECT_STORE_TYPE` | no | `local_object_store` | Object store: `local_object_store` (in-memory) or `s3` | | `VSS_AGENT_REPORTS_BASE_URL` | no | — | Base URL for generated report assets | | `VSS_AGENT_VERSION` | no | — | Version tag (used in telemetry project name) | | `PHOENIX_ENDPOINT` | no | — | Phoenix tracing endpoint (e.g. `http://HOST:6006`) | | `EVAL_LLM_JUDGE_NAME` | no | same as `LLM_NAME` | Model used for evaluation judge | | `EVAL_LLM_JUDGE_BASE_URL` | no | same as `LLM_BASE_URL` | Endpoint for evaluation judge | | `NGC_CLI_API_KEY` | cond. | — | Required when `LLM_MODE` / `VLM_MODE` is `local` or `local_shared` (Docker Compose) | | `NVIDIA_API_KEY` | cond. | — | Required for build.nvidia.com remote endpoints | ## Testing ```bash uv run pytest tests/unit_test/ -v ``` With coverage: ```bash uv run pytest tests/unit_test/ --cov=src/vss_agents --cov-report=term-missing -v ``` ## Contributing 1. Fork the repository and create a feature branch. 2. Install dev dependencies: `uv sync --group dev` 3. Install pre-commit hooks: `pre-commit install` Hooks include [gitleaks](https://github.com/gitleaks/gitleaks) for secret scanning, installed automatically as a Go binary via the pre-commit framework. 4. Run checks: ```bash uv run pytest tests/unit_test/ -v uv run ruff check src/ uv run ruff format --check src/ uv run mypy src/vss_agents/ ``` 5. Submit a pull request. ## License [Apache-2.0](LICENSE.md). Third-party licenses: [LICENSE-3rd-party.txt](LICENSE-3rd-party.txt). ================================================ FILE: agent/docker/Dockerfile ================================================ # Multi-architecture Dockerfile for production builds (AMD64/ARM64) ARG USER_ID=1000 ARG GROUP_ID=1000 # Builder stage - need this since distroless has no package manager FROM python:3.13-bookworm AS builder ARG USER_ID=1000 ARG GROUP_ID=1000 ARG TARGETPLATFORM ARG TARGETARCH ARG BUILDPLATFORM ARG UV_LINK_MODE=copy # Install all dependencies needed for building and runtime # Note: ninja-build, libcairo2-dev, pkg-config are needed for building pycairo on ARM64 # (no pre-built wheel available, so it compiles from source) RUN echo "Building for platform: ${TARGETPLATFORM:-unknown}, arch: ${TARGETARCH:-unknown}" && \ apt-get update -y && \ apt-get install -y --no-install-recommends \ curl ca-certificates binutils \ ninja-build libcairo2-dev pkg-config && \ rm -rf /var/lib/apt/lists/* # Create app directory RUN mkdir -p /vss-agent && \ chown ${USER_ID}:${GROUP_ID} /vss-agent COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ # Set version for setuptools-scm ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_VSS_AGENT=0.1.1 ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 ENV GIT_LFS_SKIP_SMUDGE=1 WORKDIR /vss-agent # Copy project files COPY --chown=${USER_ID}:${GROUP_ID} agent/pyproject.toml /vss-agent COPY --chown=${USER_ID}:${GROUP_ID} agent/LICENSE-3rd-party.txt /vss-agent COPY --chown=${USER_ID}:${GROUP_ID} agent/uv.lock /vss-agent COPY --chown=${USER_ID}:${GROUP_ID} agent/src/vss_agents /vss-agent/src/vss_agents COPY --chown=${USER_ID}:${GROUP_ID} agent/docker/cleanup_vulnerabilities.py /vss-agent/cleanup_vulnerabilities.py COPY --chown=${USER_ID}:${GROUP_ID} agent/docker/verify_ffmpeg_tarball.py /vss-agent/verify_ffmpeg_tarball.py COPY --chown=${USER_ID}:${GROUP_ID} agent/3rdparty/ffmpeg /vss-agent/third_party/ffmpeg # Fail early if FFmpeg source tarball is missing or invalid (required for LGPL compliance) RUN python3 /vss-agent/verify_ffmpeg_tarball.py # Create virtual environment and install dependencies # Use architecture-specific cache to avoid ARM64/AMD64 conflicts RUN --mount=type=cache,id=uv_cache_${TARGETARCH},target=/root/.cache/uv,sharing=private \ uv venv --seed .venv && \ . .venv/bin/activate && \ uv sync --frozen --no-dev --no-editable --link-mode copy RUN uv pip uninstall setuptools RUN rm -rf /vss-agent/.venv/lib/python3.13/site-packages/setuptools RUN find /vss-agent/.venv -name "*.exe" -delete # Cleanup to reduce image size RUN find /vss-agent/.venv -type d -name "__pycache__" -exec rm -rf {} + && \ find /vss-agent/.venv -type d -name "tests" -exec rm -rf {} + && \ find /vss-agent/.venv -type f -name "*.pyc" -delete && \ find /vss-agent/.venv -type f -name "*.pyo" -delete && \ find /vss-agent/.venv -type f -name "*.a" -delete # Remove PyTorch test binaries and unused components (~47 MB) RUN find /vss-agent/.venv -type f -path "*/torch/bin/test_*" -delete && \ find /vss-agent/.venv -type d -path "*/torch/test" -exec rm -rf {} + 2>/dev/null || true && \ rm -f /vss-agent/.venv/lib/python3.13/site-packages/torch/bin/protoc* || true # Remove imageio_ffmpeg binary if present (~76 MB) - not needed since we use opencv RUN rm -rf /vss-agent/.venv/lib/python3.13/site-packages/imageio_ffmpeg 2>/dev/null || true # Security patch stage - download patched OpenSSL libraries # CVE-2025-69419, CVE-2025-69420, CVE-2025-15467: Upgrade libssl3 to 3.0.19-1~deb12u1 FROM debian:bookworm AS security-patches ARG TARGETARCH # Download the patched libssl3 package for the target architecture RUN apt-get update && \ apt-get install -y --no-install-recommends wget ca-certificates && \ mkdir -p /patches && \ if [ "$TARGETARCH" = "amd64" ]; then \ wget -O /patches/libssl3.deb http://deb.debian.org/debian/pool/main/o/openssl/libssl3_3.0.19-1~deb12u1_amd64.deb; \ elif [ "$TARGETARCH" = "arm64" ]; then \ wget -O /patches/libssl3.deb http://deb.debian.org/debian/pool/main/o/openssl/libssl3_3.0.19-1~deb12u1_arm64.deb; \ fi && \ cd /patches && \ dpkg-deb -x libssl3.deb /patches/libssl3-extracted && \ rm -rf /var/lib/apt/lists/* # Runtime stage - using NVIDIA distroless production image (supports AMD64 and ARM64) FROM nvcr.io/nvidia/distroless/python:3.13-v3.1.3 AS runtime ARG USER_ID=1000 ARG GROUP_ID=1000 ARG TARGETPLATFORM ARG TARGETARCH # Copy cleanup script (as root to ensure proper permissions) COPY --from=builder /vss-agent/cleanup_vulnerabilities.py /tmp/cleanup_vulnerabilities.py # Ensure we're running as root (UID 0) for cleanup operations # Distroless images don't have /etc/passwd, so use numeric UID USER 0 # Remove unnecessary libraries using Python exec form (no shell needed) RUN ["/usr/local/bin/python3", "/tmp/cleanup_vulnerabilities.py"] # Remove the cleanup script after use RUN ["/usr/local/bin/python3", "-c", "import os; os.remove('/tmp/cleanup_vulnerabilities.py')"] # Remove the openssl CLI binary from the base image - the application only needs # libssl3.so/libcrypto.so shared libraries, not the CLI tool. This eliminates Grype # findings for CVE-2025-15467, CVE-2025-69419, CVE-2025-69420, CVE-2025-69421 etc. RUN ["/usr/local/bin/python3", "-c", "import os; os.path.exists('/usr/bin/openssl') and os.remove('/usr/bin/openssl')"] # Copy patched OpenSSL libraries to fix CVE-2025-69419, CVE-2025-69420, CVE-2025-15467 # These replace the base image libssl3 with 3.0.19-1~deb12u1 # Copy directly to architecture-specific paths where the runtime image expects them COPY --from=security-patches /patches/libssl3-extracted/usr/lib/*-linux-gnu*/libssl.so.* /usr/lib/x86_64-linux-gnu/ COPY --from=security-patches /patches/libssl3-extracted/usr/lib/*-linux-gnu*/libssl.so.* /usr/lib/aarch64-linux-gnu/ COPY --from=security-patches /patches/libssl3-extracted/usr/lib/*-linux-gnu*/libcrypto.so.* /usr/lib/x86_64-linux-gnu/ COPY --from=security-patches /patches/libssl3-extracted/usr/lib/*-linux-gnu*/libcrypto.so.* /usr/lib/aarch64-linux-gnu/ # Copy application without any dev needs COPY --from=builder --chown=${USER_ID}:${GROUP_ID} /vss-agent/.venv /vss-agent/.venv COPY --from=builder --chown=${USER_ID}:${GROUP_ID} /vss-agent/third_party/ffmpeg /vss-agent/third_party/ffmpeg COPY --from=builder --chown=${USER_ID}:${GROUP_ID} /vss-agent/LICENSE-3rd-party.txt /vss-agent/LICENSE-3rd-party.txt FROM runtime AS agent-runtime ARG TARGETARCH # Set environment variables ENV PYTHONUNBUFFERED=1 ENV PATH="/usr/local/bin:/vss-agent/.venv/bin:$PATH" # Set architecture-specific library paths based on Debian multiarch # Note: We include both possible paths for compatibility ENV LD_LIBRARY_PATH="/usr/lib/x86_64-linux-gnu:/usr/lib/aarch64-linux-gnu:${LD_LIBRARY_PATH}" ENV FONTCONFIG_PATH="/etc/fonts" ENV XDG_DATA_DIRS="/usr/share" # Default config (will be overridden by docker-compose env vars) ENV HOST="0.0.0.0" ENV PORT="8000" WORKDIR /vss-agent # Switch to non-root user USER ${USER_ID}:${GROUP_ID} # Default CMD can be overridden by docker-compose ENTRYPOINT ["/vss-agent/.venv/bin/nat"] CMD ["serve", "--host", "0.0.0.0", "--port", "8000"] ================================================ FILE: agent/docker/cleanup_vulnerabilities.py ================================================ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Script to remove vulnerable libexpat libraries from Docker image. Designed to run in distroless images without shell. """ import glob import os import shutil import sys def remove_path(path): """Remove a file or directory, handling errors gracefully.""" try: if os.path.isdir(path): shutil.rmtree(path) print(f"✓ Removed directory: {path}") return True elif os.path.isfile(path) or os.path.islink(path): os.remove(path) print(f"✓ Removed file: {path}") return True else: print(f"⚠ Path does not exist or is not a file/directory: {path}", file=sys.stderr) return False except PermissionError as e: print(f"✗ Permission denied: {path}: {e}", file=sys.stderr) return False except Exception as e: print(f"✗ Could not remove {path}: {e}", file=sys.stderr) return False def find_all_expat_files(): """Recursively find all libexpat files in common locations.""" search_paths = [ "/usr/lib", "/var/lib/dpkg", "/usr/share/doc", "/usr/share/doc-base", ] found = [] for base_path in search_paths: if not os.path.exists(base_path): continue for root, dirs, files in os.walk(base_path): for item in dirs + files: # Only look for expat files (NOT sqlite) if "expat" in item.lower(): full_path = os.path.join(root, item) found.append(full_path) return found def main(): """Remove vulnerable libraries and their metadata.""" print("=" * 70) print("VULNERABILITY CLEANUP SCRIPT") print("=" * 70) print(" Only removing libexpat files") # First, do a comprehensive search to see what exists print("\n🔍 Scanning for libexpat files...") all_expat = find_all_expat_files() if all_expat: print(f"Found {len(all_expat)} libexpat-related files:") for path in sorted(all_expat): size = os.path.getsize(path) if os.path.isfile(path) else 0 file_type = "DIR" if os.path.isdir(path) else "FILE" print(f" [{file_type}] {path} ({size} bytes)") else: print(" No libexpat files found") # Patterns for files/directories to remove # NOTE: Removed libsqlite3 patterns - application needs it! patterns = [ # Expat libraries (both libexpat and libexpatw variants) "/usr/lib/*/libexpat.so*", "/usr/lib/*/libexpatw.so*", "/usr/lib/*/*/libexpat.so*", "/usr/lib/*/*/libexpatw.so*", # Expat dpkg metadata "/var/lib/dpkg/status.d/libexpat*", "/var/lib/dpkg/info/libexpat*", # Expat documentation "/usr/share/doc/libexpat*", "/usr/share/doc-base/libexpat*", ] removed_count = 0 failed_count = 0 print(f"\n🧹 Attempting to remove files using {len(patterns)} patterns...") for pattern in patterns: print(f"\n Pattern: {pattern}") matches = glob.glob(pattern, recursive=False) if matches: print(f" → Found {len(matches)} matches") for match in matches: if remove_path(match): removed_count += 1 else: failed_count += 1 else: print(" → No matches") # Verify cleanup print("\n🔍 Verifying cleanup...") remaining = find_all_expat_files() print(f"\n{'=' * 70}") print("CLEANUP SUMMARY") print(f"{'=' * 70}") print(f"✓ Successfully removed: {removed_count} libexpat items") if failed_count > 0: print(f"✗ Failed to remove: {failed_count} items") if remaining: print(f"⚠ Still remaining: {len(remaining)} libexpat-related items") for path in sorted(remaining): print(f" {path}") print("\n⚠️ WARNING: libexpat cleanup incomplete!") return 1 else: print("✓ All libexpat files successfully removed") print(f"{'=' * 70}") return 0 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: agent/docker/verify_ffmpeg_tarball.py ================================================ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Verify that the FFmpeg source tarball exists and is a valid gzip file. This catches two failure modes: 1. Tarball missing entirely (file not present) 2. LFS pointer file instead of actual tarball (git-lfs fetch failed) Usage: python3 docker/verify_ffmpeg_tarball.py [--path DIR] Exit codes: 0 - Tarball found and valid 1 - Tarball missing or invalid """ import argparse import gzip from pathlib import Path import sys DEFAULT_PATH = "3rdparty/ffmpeg" DOCKER_PATH = "/vss-agent/third_party/ffmpeg" def find_tarball(search_dir: Path) -> Path | None: """Find FFmpeg tarball in the given directory.""" candidates = list(search_dir.glob("FFmpeg-*.tar.gz")) return candidates[0] if candidates else None def is_valid_gzip(filepath: Path) -> bool: """Check if file is a valid gzip file (not an LFS pointer).""" try: with gzip.open(filepath, "rb") as f: # Read first few bytes to verify it's valid gzip f.read(1024) return True except (gzip.BadGzipFile, OSError): return False def get_file_info(filepath: Path) -> str: """Get human-readable file info.""" if not filepath.exists(): return "does not exist" size = filepath.stat().st_size if size < 1024: return f"{size} bytes (likely LFS pointer)" elif size < 1024 * 1024: return f"{size / 1024:.1f} KB" else: return f"{size / (1024 * 1024):.1f} MB" def main() -> int: parser = argparse.ArgumentParser(description="Verify FFmpeg source tarball.") parser.add_argument( "--path", default=None, help=f"Directory containing FFmpeg tarball (default: {DEFAULT_PATH} or {DOCKER_PATH})", ) args = parser.parse_args() # Auto-detect path: use Docker path if it exists, otherwise default if args.path: search_dir = Path(args.path) elif Path(DOCKER_PATH).exists(): search_dir = Path(DOCKER_PATH) else: search_dir = Path(DEFAULT_PATH) print(f"[ffmpeg-tarball] Checking directory: {search_dir}") if not search_dir.exists(): print(f"[ffmpeg-tarball] ERROR: Directory does not exist: {search_dir}") return 1 tarball = find_tarball(search_dir) if not tarball: print(f"[ffmpeg-tarball] ERROR: No FFmpeg-*.tar.gz found in {search_dir}") print(f"[ffmpeg-tarball] Contents: {list(search_dir.iterdir())}") return 1 file_info = get_file_info(tarball) print(f"[ffmpeg-tarball] Found: {tarball.name} ({file_info})") if not is_valid_gzip(tarball): print(f"[ffmpeg-tarball] ERROR: {tarball.name} is not a valid gzip file") print("[ffmpeg-tarball] This usually means git-lfs fetch failed and the file is an LFS pointer.") print("[ffmpeg-tarball] Run: git lfs pull --include='3rdparty/ffmpeg/*'") # Show first few bytes to help debug try: content = tarball.read_bytes()[:200].decode("utf-8", errors="replace") print(f"[ffmpeg-tarball] File content preview: {content[:100]}...") except Exception: pass return 1 print(f"[ffmpeg-tarball] OK: Valid gzip tarball ({file_info})") return 0 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: agent/pyproject.toml ================================================ [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" [tool.hatch.version.raw-options] root = ".." [tool.hatch.metadata] allow-direct-references = true [tool.hatch.build.targets.wheel] packages = ["src/vss_agents"] [tool.uv] prerelease = "allow" managed = true index-strategy = "unsafe-best-match" # Security: constrain transitive dependencies to patch HIGH/CRITICAL CVEs constraint-dependencies = [ "cryptography>=46.0.5", # Fix CVE-2026-26007 (SECT curve subgroup attack) "langchain-community>=0.3.27", # Fix CVE-2025-6984 (XXE in EverNoteLoader) "langchain-core>=1.2.11", # Fix CVE-2025-68664, CVE-2025-65106, CVE-2026-26013 (SSRF in ChatOpenAI) "langgraph-checkpoint>=4.0.0", # Fix CVE-2025-64439 (RCE in JsonPlusSerializer) & CVE-2026-27794 (BaseCache pickle) "pillow>=12.1.1", # Fix CVE-2026-25990 (PSD out-of-bounds write) "pypdf>=6.7.3", # Fix CVE-2026-27888 (XFA decompression bomb) & CVE-2026-27628 (infinite loop) ] # Override nvidia-nat-core's fastapi~=0.119.0 pin to allow starlette>=0.49.1 override-dependencies = [ "fastapi>=0.121.0", ] [[tool.uv.index]] name = "nvidia" url = "https://pypi.nvidia.com/nvidia-nat/" [[tool.uv.index]] name = "pypi" url = "https://pypi.org/simple" explicit = true [[tool.uv.index]] name = "pytorch-cpu" url = "https://download.pytorch.org/whl/cpu" explicit = true [project] name = "vss_agents" dynamic = ["version"] dependencies = [ # Keep package version constraints as open as possible to avoid conflicts with other packages. Always define a minimum # version when adding a new package. If unsure, default to using `~=` instead of `==`. When using `~=`, use 2 digits # of precision in the version specifier. For example, use `~=1.2` instead of `~=1.2.3` and `~=0.1.3` instead of # `~=0.1.3.5`. # Keep sorted!!! # s3 for s3 object store "docker>=7.1.0", "duckdb>=1.5.0.dev44", "langchain-core >= 1.2.11", "langchain-nvidia-ai-endpoints>=1.0.4", "langgraph-checkpoint >= 4.0.0", "markdown>=3.4.0", "matplotlib>=3.8.0", "mcp >= 1.23.0", "nvidia-nat[async-endpoints,langchain,mcp,opentelemetry,phoenix,profiling,s3]==1.5.0a20260218", "opencv-python-headless>=4.13.0.92", "protobuf>=6.33.5", "pydantic >=2.11,<3", "python-multipart>=0.0.22", "sentence-transformers>=3.0.0", "starlette >= 0.49.1", "tiktoken>=0.9.0", "torch>=2.5.0", "urllib3>=2.6.3", "xhtml2pdf>=0.2.11", "elasticsearch~=8.17.0", ] requires-python = ">=3.13,<3.15" description = "Deep Search Agent" license = "Apache-2.0" keywords = ["ai", "rag", "agents"] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] authors = [{ name = "NVIDIA Corporation" }] maintainers = [{ name = "NVIDIA Corporation" }] [dependency-groups] dev = [ "ipdb>=0.13.13", "ipykernel>=6.29.5", "ipython>=9.0.2", "mypy>=1.15.0", "pre-commit>=4.2.0", "pytest>=8.4.1", "pytest-asyncio>=0.24", "ruff>=0.12.0", "nvidia-nat[test]==1.5.0a20260218", "pytest-cov>=6.1", "types-requests>=2.32.4.20260107", "types-markdown>=3.10.0.20251106", "types-pyyaml>=6.0.12.20250915", "types-python-dateutil>=2.9.0.20260124", "detect-secrets>=1.5.0", ] eval = [ "nvidia-nat[weave]==1.5.0a20260218", "spacy>=3.7,<4.0", ] [project.entry-points.'nat.components'] vss_tools = "vss_agents.tools.register" vss_agents = "vss_agents.agents.register" vss_api = "vss_agents.api.register" vss_va_mcp = "vss_agents.video_analytics.tools" vss_evaluators = "vss_agents.evaluators.register" vst_tools = "vss_agents.tools.vst.register" [tool.uv.sources] # nvidia index currently only has langchain-nvidia-ai-endpoints up to 1.0.3; source = pypi so uv gets versions >1.0.3 from PyPI langchain-nvidia-ai-endpoints = { index = "pypi" } torch = [ { index = "pytorch-cpu", marker = "platform_machine == 'x86_64' or platform_machine == 'aarch64'" }, ] torchvision = [ { index = "pytorch-cpu", marker = "platform_machine == 'x86_64' or platform_machine == 'aarch64'" }, ] [tool.ruff] # Basic settings line-length = 120 # Match your existing standards # Enable pycodestyle (`E`), Pyflakes (`F`), and isort (`I`) codes select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "ARG", "SIM", "TCH", "Q", "RUF"] # Never enforce `E501` (line length violations) in doctest code. [tool.ruff.per-file-ignores] "__init__.py" = ["F401"] "tests/*" = ["ARG001", "ARG002", "SIM117", "B017"] # Test files commonly have unused mock args and nested with [tool.ruff.lint] # ignore maximum length for docstring, comments, and string ignore = ["E501", "W505", "SIM108", "B024"] # B024: ABC without abstract methods (ParserMixin pattern) # Configure string formatting [tool.ruff.format] # Enable auto-formatting of code examples in docstrings. Markdown, # reStructuredText code/literal blocks and doctests are all supported. docstring-code-format = true # Line length for code blocks in docstrings docstring-code-line-length = 120 # Like Black, use double quotes for strings. quote-style = "double" # Like Black, indent with spaces, rather than tabs. indent-style = "space" # Like Black, respect magic trailing commas. skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" # Configure string formatting [tool.ruff.pycodestyle] # Maximum line length for docstrings max-doc-length = 120 [tool.ruff.isort] # Force sort within sections. force-sort-within-sections = true # Known first-party modules. known-first-party = ["deep_search"] # Known third-party modules. known-third-party = ["langchain", "llama_index", "crewai", "semantic_kernel", "mem0ai", "zep_cloud"] # Force separate parenthesized imports into separate lines. split-on-trailing-comma = false # Combine imports from the same module into a single `from` statement. combine-as-imports = true # Force `from` imports to wrap rather than hoist. force-wrap-aliases = true # Generate a `pyproject.toml` section when using `--generate-pyproject`. section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] # Sort imports by type, which is equivalent to `--type-group=stdlib, --type-group=thirdparty, --type-group=firstparty, --type-group=local`. order-by-type = true # Force all imports to be sorted as a single section. force-single-line = true # Default section for imports. default-section = "third-party" [tool.coverage.run] source = ["src/vss_agents"] omit = [ # ============================================================ # DEPRECATED FILES # ============================================================ # Legacy report agent - superseded by new implementation "*/agents/report_agent_old.py", # ============================================================ # ENTRY POINTS / SCRIPTS (not library code) # ============================================================ # FastAPI worker entry point script "*/api/custom_fastapi_worker.py", # Environment setup script loaded at Python startup "*/sitecustomize.py", # ============================================================ # TEST FILES # ============================================================ "*/tests/*", "*/test_*.py", # ============================================================ # COMPLEX ASYNC AGENT ORCHESTRATION # Requires extensive NAT builder mocking, LLM chain mocking, # and complex state management simulation # ============================================================ # Main orchestration agent - complex LangGraph state machine with tool routing "*/agents/top_agent.py", # Report generation agent - complex multi-step LLM workflow "*/agents/report_agent.py", # Multi-report coordination - orchestrates multiple report agents "*/agents/multi_report_agent.py", # ============================================================ # EXTERNAL SERVICE INTEGRATIONS # Requires mock servers or extensive HTTP/API mocking for # Elasticsearch, VST, VLM, and other external services # ============================================================ # Elasticsearch client - requires ES mock server "*/video_analytics/es_client.py", # VST tools - require VST API mocking # VST files tool - requires VST storage API mocking "*/tools/vst_files.py", # VST download tool - requires VST storage + file download mocking "*/tools/vst_download.py", # Video understanding - requires VLM API + S3 mocking "*/tools/video_understanding.py", # LVS video understanding - requires LVS backend API mocking "*/tools/lvs_video_understanding.py", # Geolocation tool - requires OpenStreetMap API mocking "*/tools/geolocation.py", # S3 picture URL - requires S3/MinIO + OpenCV mocking "*/tools/s3_picture_url.py", # Incidents tool - requires DuckDB + S3 integration mocking "*/tools/incidents.py", # ============================================================ # REPORT GENERATION # Requires PDF rendering, template engines, object store, # and complex file I/O mocking # ============================================================ # Template-based PDF report generation - xhtml2pdf mocking "*/tools/template_report_gen.py", # Video(uploaded) report generation - complex template + PDF mocking "*/tools/video_report_gen.py", # Report gen tool - object store + file system mocking "*/tools/report_gen.py", # Multi-incident formatter - chart gen + multiple tool coordination "*/tools/multi_incident_formatter.py", # FOV counts with chart - requires chart tool + histogram tool mocking "*/tools/fov_counts_with_chart.py", # ============================================================ # LLM-BASED EVALUATION # Requires LLM mocking for judge-based evaluation flows # ============================================================ # Report evaluator - LLM judge for report quality "*/evaluators/report_evaluator/evaluate.py", # Trajectory evaluator - LLM judge for agent trajectories "*/evaluators/customized_trajectory_evaluator/evaluate.py", # Evaluation compressor - LLM-based text compression "*/tools/evaluation_compressor.py", # ============================================================ # DOCKER/CONTAINER EXECUTION # Requires Docker daemon mocking and container lifecycle management # ============================================================ # Docker executor - requires Docker API mocking "*/tools/code_executor/docker_backend/docker_executor.py", # Image builder - requires Docker build API mocking "*/tools/code_executor/docker_backend/image_builder.py", ] [tool.coverage.report] # Regexes for lines to exclude from consideration exclude_lines = [ "pragma: no cover", "def __repr__", "raise AssertionError", "raise NotImplementedError", "if __name__ == .__main__.:", "if TYPE_CHECKING:", "@(abc\\.)?abstractmethod", ] ignore_errors = true [tool.pytest.ini_options] testpaths = ["tests/unit_test"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] addopts = [ "-v", "--strict-markers", "--strict-config", ] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", ] # Mypy configuration for type checking [tool.mypy] python_version = "3.13" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true disallow_untyped_calls = true disallow_incomplete_defs = true check_untyped_defs = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true warn_no_return = true warn_unreachable = true strict_equality = true show_error_codes = true show_column_numbers = true pretty = true explicit_package_bases = true namespace_packages = true ignore_missing_imports = true mypy_path = "src:stubs" # docker SDK has no type stubs; skip analysis to avoid false attr-defined errors [[tool.mypy.overrides]] module = "docker.*" follow_imports = "skip" ================================================ FILE: agent/src/sitecustomize.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import contextlib import logging from pathlib import Path from typing import TYPE_CHECKING from typing import Any if TYPE_CHECKING: from collections.abc import Callable logger = logging.getLogger(__name__) if not logger.handlers: handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) logger.addHandler(handler) logger.setLevel(logging.INFO) load_dotenv: Callable[..., Any] | None = None with contextlib.suppress(Exception): from dotenv import load_dotenv def _load_env_file(env_path: Path) -> None: """Attempt to load environment variables from ``env_path`` if available.""" if load_dotenv is None: logger.warning("python-dotenv not installed; skipping env file load for %s", env_path) return if env_path.is_file(): load_dotenv(env_path, override=False) logger.info("Loaded environment variables from %s", env_path) else: logger.warning("Env file %s not found; skipping", env_path) def _auto_load_env_files() -> None: project_root = Path(__file__).resolve().parent.parent env_pointer = project_root / ".env_file" if env_pointer.is_file(): try: target_path = env_pointer.read_text().strip() if target_path: env_path = Path(target_path).expanduser() if not env_path.is_absolute(): env_path = project_root / env_path if env_path.is_file(): logger.info("Loading environment variables from %s", env_path) _load_env_file(env_path) else: logger.warning("Env file %s not found; skipping", env_path) else: logger.warning(".env_file at %s is empty", env_pointer) except Exception: logger.exception("Error reading %s", env_pointer) else: logger.info(".env_file not found at %s", env_pointer) try: _auto_load_env_files() except Exception: logger.exception("Unhandled error during env auto-load") ================================================ FILE: agent/src/vss_agents/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/agents/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/agents/critic_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio from collections.abc import AsyncGenerator from datetime import datetime from enum import Enum import json import logging from typing import Literal from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import get_stream_id from vss_agents.utils.time_convert import iso8601_to_datetime logger = logging.getLogger(__name__) CRITIC_AGENT_PROMPT = """ You are a helpful assistant that will evaluate a video against the original prompt and evaluate whether the requested parameters are met. user_prompt: {user_prompt} Your task is to break down the user prompt into a list of parameters that are requested and evaluate whether the video meets the requested parameter. Example 1: user_prompt: "Find the man wearing a blue shirt, dark pants, and carrying a backpack" Return the output in the following format: ```json {{ "man": true, "blue shirt": true, "dark pants": true, "backpack": true }} Example 2: user_prompt: "Find the woman picking up a box" Return the output in the following format: ```json {{ "woman": true, "picking up a box": false }} Example 3: user_prompt: "Find the running person in a green jacket" Return the output in the following format: ```json {{ "person": true, "running": false, "green jacket": true }} ``` """ class CriticAgentConfig(FunctionBaseConfig, name="critic_agent"): """Config for the Critic Agent.""" critic_prompt: str = Field( default=CRITIC_AGENT_PROMPT, description="The prompt that is used to evaluate the video against the user prompt.", ) max_concurrent_verifications: int = Field( default=5, description="Maximum number of concurrent VLM calls", ge=1, ) video_analysis_tool: FunctionRef | None = Field( default=None, description="Video analysis tool to use for video analysis.", ) time_format: Literal["iso", "offset"] = Field( default="iso", description="Timestamp input format: 'iso' for ISO 8601 UTC strings (e.g. '2025-08-25T03:05:55Z'), " "'offset' for seconds since stream start. " "Must match across video_understanding, vst.video_clip, vst.snapshot, and critic_agent configs.", ) class VideoInfo(BaseModel): """Information about a video.""" # Make this type hashable so it can be used as a key in a dictionary model_config = ConfigDict(frozen=True) sensor_id: str = Field(description="The sensor ID of the video.") start_timestamp: str = Field( description="The start timestamp in UTC ISO 8601 format (e.g., '2025-08-25T03:05:55.752Z')" ) end_timestamp: str = Field( description="The end timestamp in UTC ISO 8601 format (e.g., '2025-08-25T03:06:15.752Z')" ) class CriticAgentInput(BaseModel): """Input for the Critic Agent.""" query: str = Field(description="The user query that was used to generate the search results.") videos: list[VideoInfo] = Field(description="The list of video information to evaluate.") evaluation_count: int | None = Field( default=None, description="The number of videos to evaluate. If None, all videos will be evaluated.", ge=1, ) class CriticAgentResult(Enum): """Result for a single video evaluation.""" CONFIRMED = "confirmed" REJECTED = "rejected" UNVERIFIED = "unverified" # Kind of a roundabout way to compose the output since `FunctionInfo.create` must return a BaseModel. class VideoResult(BaseModel): """Result for a single video evaluation.""" video_info: VideoInfo = Field(description="The URL of the video that was evaluated.") result: CriticAgentResult = Field(description="The result of the video evaluation.") criteria_met: dict[str, bool] | None = Field( default=None, description="A dictionary of the user prompt's criteria for each parameter and whether the video meets it or not.", ) class CriticAgentOutput(BaseModel): """Output for the Critic Agent.""" video_results: list[VideoResult] = Field(description="The list of video results.") def get_json_from_string(string: str) -> str: """Strip the JSON from the string.""" if "```json" in string: return string.split("```json")[1].split("```")[0].strip() else: return string def _convert_to_seconds(timestamp: str, video_start_dt: datetime) -> float: """Convert timestamp to seconds since video start timestamp.""" timestamp_dt = iso8601_to_datetime(timestamp) return (timestamp_dt - video_start_dt).total_seconds() @register_function(config_type=CriticAgentConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def critic_agent(config: CriticAgentConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: async def _execute_critic( critic_input: CriticAgentInput, ) -> CriticAgentOutput: """ Critic Agent to critique the search results generated by the user query. Args: critic_input (CriticAgentInput): The input to the critic agent. Returns: CriticAgentOutput: A CriticAgentOutput containing a list of VideoResult objects, one for each video. """ video_count = min(critic_input.evaluation_count or len(critic_input.videos), len(critic_input.videos)) semaphore = asyncio.Semaphore(config.max_concurrent_verifications) results: CriticAgentOutput = CriticAgentOutput(video_results=[]) async def evaluate_video(video: VideoInfo) -> VideoResult | None: if not config.video_analysis_tool: logger.warning(f"[Critic Agent] No video analysis tool configured, skipping video {video.sensor_id}") return None video_analysis_tool = await builder.get_function(config.video_analysis_tool) async with semaphore: formatted_prompt = config.critic_prompt.format(user_prompt=critic_input.query) logger.debug(f"Formatted prompt: {formatted_prompt}") try: # The critic agent always receives ISO 8601 timestamps from its callers. # When time_format is "iso", pass them through directly to the video analysis tool. # When time_format is "offset", convert ISO timestamps to seconds-since-start # because the video analysis tool expects float offsets (for uploaded video files). if config.time_format == "iso": video_analysis_input = { "sensor_id": video.sensor_id, "start_timestamp": video.start_timestamp, "end_timestamp": video.end_timestamp, "user_prompt": formatted_prompt, "vlm_reasoning": True, } else: stream_id = await get_stream_id(video.sensor_id) start_iso, end_iso = await get_timeline(stream_id) video_start_dt = iso8601_to_datetime(start_iso) # Sometimes the end timestamp is after the video end timestamp, so we need to clip the end offset. start_offset = _convert_to_seconds(video.start_timestamp, video_start_dt) end_offset = _convert_to_seconds(video.end_timestamp, video_start_dt) clip_end_offset = _convert_to_seconds(end_iso, video_start_dt) if end_offset > clip_end_offset: end_offset = clip_end_offset video_analysis_input = { "sensor_id": video.sensor_id, "start_timestamp": start_offset, "end_timestamp": end_offset, "user_prompt": formatted_prompt, "vlm_reasoning": True, } vlm_response = await video_analysis_tool.ainvoke(video_analysis_input) logger.info(f"VLM response: {vlm_response}") except Exception as e: # Failing one video analysis call is not a critical error, so we return None. logger.error(f"Error calling video analysis tool: {e}") return None try: criteria_dict: dict[str, bool] = json.loads(get_json_from_string(vlm_response)) # For now, we assume the video fails if any of the parameters are not met result = CriticAgentResult.CONFIRMED for value in criteria_dict.values(): if not value: result = CriticAgentResult.REJECTED break logger.debug(f"Video {video} criteria dict: {criteria_dict}") return VideoResult(video_info=video, result=result, criteria_met=criteria_dict) except Exception as e: # Failing one video analysis call is not a critical error, so we return None. logger.error(f"Error parsing VLM response: {e}") return VideoResult(video_info=video, result=CriticAgentResult.UNVERIFIED, criteria_met={}) tasks = [evaluate_video(video) for video in critic_input.videos[:video_count] if video.sensor_id] video_results = await asyncio.gather(*tasks) results.video_results = [result for result in video_results if result is not None] logger.info(f"Critic agent results: {results.model_dump_json(indent=2)}") return results yield FunctionInfo.create( single_fn=_execute_critic, description=_execute_critic.__doc__, input_schema=CriticAgentInput, single_output_schema=CriticAgentOutput, ) ================================================ FILE: agent/src/vss_agents/agents/data_models.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import enum from typing import Any from typing import Literal from pydantic import BaseModel from pydantic import Field # ========== EXISTING ENUMS AND MODELS ========== class AgentDecision(enum.StrEnum): """Decision of the agent""" TOOL = "tool" END = "finished" AGENT = "agent" SUPERVISOR = "supervisor" class AgentMessageChunkType(enum.StrEnum): """Type of the message chunk""" THOUGHT = "thought" TOOL_CALL = "tool_call" SUBAGENT_CALL = "subagent_call" ERROR = "error" FINAL = "final" class AgentMessageChunk(BaseModel): """Message chunk for the Report Agent""" type: AgentMessageChunkType = Field(AgentMessageChunkType.THOUGHT, description="The type of the message chunk") content: str = Field("", description="The content of the message chunk") class AgentOutput(BaseModel): """ Standardized output model for agents (report_agent, multi_report_agent, etc.). This model provides: - messages: Conversational responses to the user - side_effects: Generated artifacts (HTML reports, PDFs, charts, media URLs) - metadata: Execution information (timing, tool calls, confidence, etc.) - status: Execution status indicator - error_message: Error details if applicable """ messages: list[str] = Field(default_factory=list, description="Conversational output messages for the user") side_effects: dict[str, Any] = Field( default_factory=dict, description="UI rendering artifacts and generated outputs. May include 'report_html', 'report_pdf_url', " "'report_markdown_url', 'snapshot_urls', 'video_urls', 'charts', 'chart_html', 'formatted_incidents', etc.", ) metadata: dict[str, Any] = Field( default_factory=dict, description="Execution metadata such as 'incident_count', 'generation_time_ms', " "'tools_called', 'agent_iterations', 'confidence', etc.", ) status: Literal["success", "partial_success", "error"] = Field( default="success", description="Status of the agent execution" ) error_message: str | None = Field( default=None, description="Error message if status is 'error' or 'partial_success'" ) # ========== NOTE ========== # ReportMode and ReportAgentInput are specific to report_agent.py # MultiReportAgentInput is specific to multi_report_agent.py # AgentOutput is shared by both report_agent and multi_report_agent ================================================ FILE: agent/src/vss_agents/agents/multi_report_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Multi-Incident Report Agent - Deterministic tool-calling workflow for multiple incidents. This agent fetches and formats multiple incidents with URLs, charts, and visualizations. """ from collections.abc import AsyncGenerator import logging import time from typing import Literal from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.agents.data_models import AgentMessageChunk from vss_agents.agents.data_models import AgentMessageChunkType from vss_agents.agents.data_models import AgentOutput logger = logging.getLogger(__name__) class MultiReportAgentInput(BaseModel): """ Input for the Multi-Incident Report Agent. This agent handles fetching and formatting multiple incidents within a specified time range. """ source: str = Field(..., description="Source to fetch incidents from (e.g., sensor ID, place)") source_type: Literal["sensor", "place"] = Field(..., description="Type of the source (must be 'sensor', 'place')") start_time: str | None = Field( default=None, description="Optional start time in ISO format (e.g., '2025-09-22T14:00:00.000Z'). If omitted, fetches most recent incidents.", ) end_time: str | None = Field( default=None, description="Optional end time in ISO format (e.g., '2025-09-22T15:00:00.000Z'). If omitted, fetches most recent incidents.", ) # Optional parameter - if not provided, falls back to config.max_incidents max_result_size: int | None = Field( default=None, description="Maximum number of incidents to return. If not specified, uses max_incidents from config.", gt=0, ) class MultiReportAgentConfig(FunctionBaseConfig, name="multi_report_agent"): """Config for the multi-incident report agent.""" # Tool references multi_incident_tool: FunctionRef = Field( description="Tool to format multiple incidents with URLs/charts (e.g., multi_incident_formatter)" ) # Configuration defaults max_incidents: int = Field( default=10000, ge=1, le=10000, description="Maximum number of incidents to fetch. " "Used when max_result_size is not specified in the request. " "UI will display just the top incidents, but charts will show all fetched incidents.", ) @register_function(config_type=MultiReportAgentConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def multi_report_agent(config: MultiReportAgentConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Multi-incident report agent. Executes deterministic tool sequence: - multi_incident_formatter → fetches incidents, adds URLs, formats, generates charts Args: config: Configuration with tool references and max_incidents default builder: NAT builder for tool resolution Yields: FunctionInfo for the multi report agent """ # Get tool references multi_incident_tool = await builder.get_tool(config.multi_incident_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) logger.info("Multi Report Agent tools initialized successfully") async def _execute_multi_report( source: str, source_type: str, start_time: str | None = None, end_time: str | None = None, max_result_size: int | None = None, ) -> AsyncGenerator[AgentMessageChunk]: """ Execute multi-incident report generation. Args: source: Source to fetch incidents from (sensor ID, place) source_type: Type of the source start_time: Optional start time in ISO format end_time: Optional end time in ISO format max_result_size: Maximum number of incidents (if None, uses config.max_incidents) Yields: AgentMessageChunk objects for tool calls and final result """ logger.info("Generating multi-incident report") start_time_exec = time.time() try: # Use max_result_size from input if provided, otherwise fallback to config.max_incidents effective_max_size = max_result_size if max_result_size is not None else config.max_incidents logger.info( f"Calling multi_incident_formatter for {source_type} {source} (max {effective_max_size} results)" ) # Yield tool call chunk tool_args = { "source": source, "source_type": source_type, "start_time": start_time, "end_time": end_time, "max_result_size": effective_max_size, } yield AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content=f"Tool: multi_incident_formatter\nArgs: {tool_args}" ) # Call multi_incident_formatter with translated source_type formatter_result = await multi_incident_tool.ainvoke(tool_args) logger.debug(f"multi_incident_formatter returned: {type(formatter_result)}") # Extract data from formatter result formatted_incidents = "" incident_count = 0 side_effects = {} if hasattr(formatter_result, "formatted_incidents"): formatted_incidents = formatter_result.formatted_incidents incident_count = formatter_result.total_incidents if formatter_result.chart_html: side_effects["chart_html"] = formatter_result.chart_html elif isinstance(formatter_result, dict): formatted_incidents = formatter_result.get("formatted_incidents", "") incident_count = formatter_result.get("total_incidents", 0) if "chart_html" in formatter_result: side_effects["chart_html"] = formatter_result["chart_html"] else: formatted_incidents = str(formatter_result) logger.info("Multi-incident report generated successfully") execution_time_ms = int((time.time() - start_time_exec) * 1000) agent_output = AgentOutput( messages=[ f"Found {incident_count} incident{'s' if incident_count != 1 else ''} for {source_type} {source}", formatted_incidents, ], side_effects=side_effects, status="success", metadata={ "incident_count": incident_count, "source": source, "source_type": source_type, "report_type": "multi_incident", "generation_time_ms": execution_time_ms, "max_result_size": effective_max_size, }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=agent_output.model_dump_json()) except (ValueError, KeyError, AttributeError) as e: logger.exception("Failed to execute multi-incident report") execution_time_ms = int((time.time() - start_time_exec) * 1000) error_output = AgentOutput( messages=[f"Error generating multi-incident report: {e!s}"], status="error", error_message=f"Failed to generate multi-incident report: {e!s}", metadata={ "generation_time_ms": execution_time_ms, "report_type": "multi_incident", }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=error_output.model_dump_json()) except Exception: logger.exception("Unexpected error in multi-incident report execution") execution_time_ms = int((time.time() - start_time_exec) * 1000) error_output = AgentOutput( messages=["Unexpected error generating multi-incident report"], status="error", error_message="Unexpected error in multi-incident report execution", metadata={ "generation_time_ms": execution_time_ms, "report_type": "multi_incident", }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=error_output.model_dump_json()) # Register the function yield FunctionInfo.create( stream_fn=_execute_multi_report, description=( "Generate multi-incident reports showing formatted lists of multiple incidents " "with URLs, charts, and visualizations. " "Streams reasoning steps showing tool calls to multi_incident_formatter." ), input_schema=MultiReportAgentInput, stream_output_schema=AgentMessageChunk, ) ================================================ FILE: agent/src/vss_agents/agents/postprocessing/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Postprocessing module for output validation.""" from vss_agents.agents.postprocessing.data_models import POSTPROCESSING_FEEDBACK_MARKER from vss_agents.agents.postprocessing.data_models import PostprocessingConfig from vss_agents.agents.postprocessing.data_models import PostprocessingResult from vss_agents.agents.postprocessing.data_models import ValidatorResult from vss_agents.agents.postprocessing.postprocessing_node import PostprocessingNode __all__ = [ "POSTPROCESSING_FEEDBACK_MARKER", "PostprocessingConfig", "PostprocessingNode", "PostprocessingResult", "ValidatorResult", ] ================================================ FILE: agent/src/vss_agents/agents/postprocessing/data_models.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Data models for postprocessing module.""" from __future__ import annotations from pydantic import BaseModel from pydantic import Field # Marker used to identify postprocessing feedback messages in scratchpad POSTPROCESSING_FEEDBACK_MARKER = "[YOUR PREVIOUS RESPONSE FAILED POSTPROCESSING VALIDATION. HERE IS THE FEEDBACK]" class ValidatorResult(BaseModel): """Result from a validator.""" name: str passed: bool issues: list[str] = Field(default_factory=list) class PostprocessingResult(BaseModel): """Result from postprocessing node.""" passed: bool feedback: str = "" # --- Config models --- class BaseValidatorConfig(BaseModel): """Base configuration for validators.""" feedback_template: str = "" class URLValidatorConfig(BaseValidatorConfig): """Configuration for URL validator.""" timeout: float = 10.0 max_retries: int = 2 internal_ip: str class NonEmptyResponseValidatorConfig(BaseValidatorConfig): """Configuration for non-empty response validator.""" pass class LLMBasedRuleValidatorConfig(BaseValidatorConfig): """Configuration for LLM-based rule validator.""" prompt_template: str = "" max_retries: int = 2 llm_name: str | None = ( None # Optional: LLM used will defaults to workflow LLM. Specify if you want to use a different LLM. ) class ValidatorsConfig(BaseModel): """Configuration for all validators.""" url_validator: URLValidatorConfig | None = None non_empty_response_validator: NonEmptyResponseValidatorConfig | None = None llm_based_rule_validator: LLMBasedRuleValidatorConfig | None = None class PostprocessingConfig(BaseModel): """Configuration for postprocessing node.""" enabled: bool = True validators: ValidatorsConfig = Field(default_factory=ValidatorsConfig) # Validation order: list of groups. Validators in same group run concurrently with aggregated feedback. # Groups run sequentially, next group only runs if previous group all passed. validation_order: list[list[str]] | None = None # Maximum wall-clock seconds for each validation group to complete. # None means no timeout (wait indefinitely). group_timeout_seconds: float | None = None # When True (default), validator exceptions and group timeouts are treated as # a pass (fail-open), preserving current behavior. When False, exceptions and # timeouts are treated as explicit failures with diagnostic feedback. fail_open_on_validator_error: bool = True ================================================ FILE: agent/src/vss_agents/agents/postprocessing/postprocessing_node.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Postprocessing node to run validators and provide feedback to agent.""" import asyncio import logging from typing import Any from langchain_core.language_models import BaseChatModel from langchain_core.messages import BaseMessage from langchain_core.messages import HumanMessage from vss_agents.agents.postprocessing.data_models import POSTPROCESSING_FEEDBACK_MARKER from vss_agents.agents.postprocessing.data_models import PostprocessingConfig from vss_agents.agents.postprocessing.data_models import PostprocessingResult from vss_agents.agents.postprocessing.validators.base import BaseValidator from vss_agents.agents.postprocessing.validators.llm_based_rule_validator import LLMBasedRuleValidator from vss_agents.agents.postprocessing.validators.non_empty_response_validator import NonEmptyResponseValidator from vss_agents.agents.postprocessing.validators.url_validator import URLValidator logger = logging.getLogger(__name__) # Registry: field_name -> validator_class _VALIDATOR_REGISTRY = { "url_validator": URLValidator, "non_empty_response_validator": NonEmptyResponseValidator, "llm_based_rule_validator": LLMBasedRuleValidator, } def _format_message(msg: BaseMessage) -> str: """Format a single message for trajectory display.""" import json from langchain_core.messages import AIMessage msg_type = type(msg).__name__ # For AIMessage, show tool calls if present if isinstance(msg, AIMessage) and msg.tool_calls: return f"[{msg_type}]: {json.dumps(msg.tool_calls, default=str)}" content = str(msg.content) if msg.content else "" # Skip placeholder content if content == "Agent wants to call tools.": return "" return f"[{msg_type}]: {content}" def extract_current_trajectory(scratchpad: list[BaseMessage]) -> str: """Extract and format the current trajectory from scratchpad. Only includes messages after the last feedback message (current attempt). Args: scratchpad: The agent's scratchpad. Returns: Formatted trajectory string. """ if not scratchpad: return "" # Find the last feedback message last_feedback_idx = -1 for i, msg in enumerate(scratchpad): if isinstance(msg, HumanMessage) and POSTPROCESSING_FEEDBACK_MARKER in str(msg.content): last_feedback_idx = i # Only include messages after the last feedback start_idx = last_feedback_idx + 1 if last_feedback_idx >= 0 else 0 current_messages = scratchpad[start_idx:] if not current_messages: return "" # Format trajectory lines = [_format_message(msg) for msg in current_messages] lines = [line for line in lines if line] # Remove empty lines return "\n".join(lines) class PostprocessingNode: """Run validators in groups and provide feedback on failure.""" def __init__( self, config: PostprocessingConfig | None = None, llm: BaseChatModel | None = None, ): self.config = config or PostprocessingConfig() self.llm = llm self.validators_by_name: dict[str, BaseValidator] = {} self.validation_order: list[list[str]] = [] self._create_validators() logger.info(f"PostprocessingNode initialized with validation_order: {self.validation_order}") def _create_validators(self) -> None: """Create validators from config.""" validators_cfg = self.config.validators for field_name in validators_cfg.model_fields: validator_config = getattr(validators_cfg, field_name, None) if validator_config is not None: if field_name not in _VALIDATOR_REGISTRY: logger.warning(f"Unknown validator: {field_name}") continue try: validator_cls = _VALIDATOR_REGISTRY[field_name] validator = validator_cls(llm=self.llm, **validator_config.model_dump()) self.validators_by_name[field_name] = validator except Exception as e: logger.warning(f"Failed to create validator {field_name}: {e}") # Set up validation order if self.config.validation_order: # Use configured order, filter out validators that weren't created self.validation_order = [ [name for name in group if name in self.validators_by_name] for group in self.config.validation_order ] self.validation_order = [g for g in self.validation_order if g] else: # Default: each validator in its own group (sequential execution) self.validation_order = [[name] for name in self.validators_by_name] async def _run_validator(self, validator: BaseValidator, **kwargs: Any) -> PostprocessingResult: """Run a single validator.""" try: result = await validator.validate(**kwargs) if result.passed: return PostprocessingResult(passed=True) else: logger.info(f"{validator.name}: FAILED with issues: {result.issues}") feedback = f"[VALIDATION FAILED]\n{validator.name}:\n{validator.format_feedback(result.issues)}" return PostprocessingResult(passed=False, feedback=feedback) except Exception as e: if self.config.fail_open_on_validator_error: logger.warning(f"{validator.name} error (fail-open): {e}") return PostprocessingResult(passed=True) else: logger.error(f"{validator.name} error (fail-closed): {e}") return PostprocessingResult( passed=False, feedback=f"[VALIDATION ERROR]\n{validator.name}: {e}", ) async def process( self, output: str, user_query: str = "", scratchpad: list[BaseMessage] | None = None, llm_reasoning: bool = False, ) -> PostprocessingResult: """Run validators in groups. Validators in same group run concurrently with aggregated feedback.""" if (not output or not output.strip()) and "non_empty_response_validator" not in self.validators_by_name: return PostprocessingResult(passed=True) trajectory = extract_current_trajectory(scratchpad or []) context = { "output": output, "user_query": user_query, "trajectory": trajectory, "llm_reasoning": llm_reasoning, } logger.info(f"Running validation_order={self.validation_order}") for group in self.validation_order: if not group: continue # Run all validators in this group concurrently async def run_validator_by_name(name: str) -> PostprocessingResult: validator = self.validators_by_name[name] return await self._run_validator(validator, **context) group_coro = asyncio.gather(*[run_validator_by_name(name) for name in group]) try: if self.config.group_timeout_seconds is not None: results = await asyncio.wait_for(group_coro, timeout=self.config.group_timeout_seconds) else: results = await group_coro except TimeoutError: if self.config.fail_open_on_validator_error: logger.warning( f"Validation group {group} timed out after {self.config.group_timeout_seconds}s (fail-open)" ) continue # treat as passed else: logger.error( f"Validation group {group} timed out after {self.config.group_timeout_seconds}s (fail-closed)" ) return PostprocessingResult( passed=False, feedback=( f"[VALIDATION TIMEOUT]\nValidation group {group} " f"exceeded {self.config.group_timeout_seconds}s timeout." ), ) # Collect all failures in this group failures = [r for r in results if not r.passed] if failures: combined_feedback = "\n\n".join(f.feedback for f in failures) logger.info(f"Validation group {group} failed: {len(failures)} validator(s)") return PostprocessingResult( passed=False, feedback=combined_feedback, ) logger.debug(f"Validation group {group}: PASSED") logger.info("All postprocessing validators PASSED") return PostprocessingResult(passed=True) ================================================ FILE: agent/src/vss_agents/agents/postprocessing/validators/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Validators for postprocessing module.""" from vss_agents.agents.postprocessing.validators.base import BaseValidator from vss_agents.agents.postprocessing.validators.llm_based_rule_validator import LLMBasedRuleValidator from vss_agents.agents.postprocessing.validators.non_empty_response_validator import NonEmptyResponseValidator from vss_agents.agents.postprocessing.validators.url_validator import URLValidator __all__ = [ "BaseValidator", "LLMBasedRuleValidator", "NonEmptyResponseValidator", "URLValidator", ] ================================================ FILE: agent/src/vss_agents/agents/postprocessing/validators/base.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Base class for validators.""" from abc import ABC from abc import abstractmethod from typing import Any from typing import ClassVar from vss_agents.agents.postprocessing.data_models import ValidatorResult class BaseValidator(ABC): """Base class for validators.""" name: ClassVar[str] = "base_validator" def __init__( self, feedback_template: str | None = None, ) -> None: """Initialize the base validator. Args: feedback_template: Template for formatting validation feedback. Use {issues} placeholder. """ self.feedback_template = feedback_template or "" @abstractmethod async def validate(self, output: str, **kwargs: Any) -> ValidatorResult: """Run the validation. Args: output: The agent's final response to validate. **kwargs: Additional context. """ pass def format_feedback(self, issues: list[str]) -> str: """Format feedback with template support. Use {issues} placeholder.""" if not issues: return "" issues_str = ", ".join(issues) if not self.feedback_template: return issues_str try: return self.feedback_template.format(issues=issues_str) except KeyError: return self.feedback_template ================================================ FILE: agent/src/vss_agents/agents/postprocessing/validators/llm_based_rule_validator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """LLM-based validator for soft rule checking.""" import logging from typing import Any from langchain_core.exceptions import LangChainException from langchain_core.exceptions import OutputParserException from langchain_core.language_models import BaseChatModel from langchain_core.messages import BaseMessage from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage from pydantic import BaseModel from pydantic import Field from vss_agents.agents.postprocessing.data_models import ValidatorResult from vss_agents.utils.reasoning_utils import get_llm_reasoning_bind_kwargs from vss_agents.utils.reasoning_utils import get_thinking_tag from .base import BaseValidator logger = logging.getLogger(__name__) class LLMBasedRuleValidatorOutput(BaseModel): """Structured output from LLM-based rule validation.""" passed: bool = Field(description="True if the response is acceptable, False if it needs improvement") feedback: str = Field(default="", description="Actionable feedback for the agent to improve") DEFAULT_PROMPT_TEMPLATE = """You are a validator. Check if the agent's response is acceptable. User's Question: {user_query} Agent's Trajectory: {trajectory} Agent's Final Response: {output} Decide if the response is acceptable (passed=True) or needs improvement (passed=False). """ class LLMBasedRuleValidator(BaseValidator): """LLM-based rule validator for validating configurable rules.""" name = "llm_based_rule_validator" def __init__( self, llm: BaseChatModel, prompt_template: str = "", feedback_template: str = "", max_retries: int = 0, **kwargs: Any, # noqa: ARG002 ) -> None: """Initialize the LLM-based rule validator. Args: llm: The LLM to use for validation. prompt_template: Custom prompt template. Use {output}, {user_query}, {trajectory} placeholders. feedback_template: Template for feedback message. Use {issues} placeholder. max_retries: Number of retries on LLM parsing/invocation errors. """ super().__init__( feedback_template=feedback_template, ) self.llm = llm self.prompt_template = prompt_template or DEFAULT_PROMPT_TEMPLATE if max_retries < 0: raise ValueError("max_retries must be >= 0") self.max_retries = max_retries async def validate(self, output: str, **kwargs: Any) -> ValidatorResult: """Validate output using LLM with structured output. Args: output: The agent's final response. **kwargs: Context including user_query, trajectory, llm_reasoning. """ user_query = kwargs.get("user_query", "") trajectory = kwargs.get("trajectory", "") llm_reasoning = kwargs.get("llm_reasoning", False) try: prompt = self.prompt_template.format( output=output, user_query=user_query or "No user query available", trajectory=trajectory or "No trajectory available", ) except KeyError as e: logger.warning(f"{self.name}: prompt template missing key: {e}; using DEFAULT_PROMPT_TEMPLATE") prompt = DEFAULT_PROMPT_TEMPLATE.format( output=output, user_query=user_query or "No user query available", trajectory=trajectory or "No trajectory available", ) # Configure LLM with reasoning mode if enabled llm = self.llm thinking_tag = get_thinking_tag(llm, llm_reasoning) if thinking_tag: logger.debug(f"{self.name}: using thinking tag: {thinking_tag}") llm_kwargs = get_llm_reasoning_bind_kwargs(llm, llm_reasoning) if llm_kwargs: logger.debug(f"{self.name}: binding with reasoning kwargs: {llm_kwargs}") llm = llm.bind(**llm_kwargs) # type: ignore[assignment] # Build messages messages: list[BaseMessage] = [] if thinking_tag: messages.append(SystemMessage(content=thinking_tag)) messages.append(HumanMessage(content=prompt)) structured_llm = llm.with_structured_output(LLMBasedRuleValidatorOutput) last_exception: Exception | None = None for attempt in range(self.max_retries + 1): try: raw_result = await structured_llm.ainvoke(messages) result = LLMBasedRuleValidatorOutput.model_validate(raw_result) logger.info(f"{self.name}: passed={result.passed}, feedback={result.feedback}") issues = [result.feedback] if not result.passed and result.feedback else [] return ValidatorResult(name=self.name, passed=result.passed, issues=issues) except (OutputParserException, LangChainException) as e: last_exception = e if attempt < self.max_retries: logger.warning(f"{self.name} attempt {attempt + 1} failed: {e}, retrying...") else: logger.warning(f"{self.name} failed after {self.max_retries + 1} attempts: {e}") except Exception as e: logger.exception(f"{self.name} unexpected error while validating: {e}") last_exception = e # Exit retry loop and fall through to the existing fail-open return break # Propagate the last exception so the node can apply the central fail-open policy raise last_exception # type: ignore[misc] ================================================ FILE: agent/src/vss_agents/agents/postprocessing/validators/non_empty_response_validator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Validator that ensures the response is not empty.""" import logging from typing import Any from vss_agents.agents.postprocessing.data_models import ValidatorResult from vss_agents.agents.postprocessing.validators.base import BaseValidator logger = logging.getLogger(__name__) class NonEmptyResponseValidator(BaseValidator): """Validates that the response is not empty.""" name = "non_empty_response_validator" def __init__( self, feedback_template: str = "", **kwargs: Any, # noqa: ARG002 ) -> None: """Initialize the non-empty response validator. Args: feedback_template: Template for feedback message. Use {issues} placeholder. """ super().__init__( feedback_template=feedback_template, ) async def validate(self, output: str, **kwargs: Any) -> ValidatorResult: # noqa: ARG002 """Validate that the output is not empty. Args: output: The response to validate. **kwargs: Additional context. Returns: ValidatorResult with pass/fail status. """ stripped = output.strip() if output else "" if not stripped: logger.info(f"{self.name}: Response is empty") return ValidatorResult( name=self.name, passed=False, issues=["Response is empty"], ) logger.info(f"{self.name}: PASSED") return ValidatorResult(name=self.name, passed=True) ================================================ FILE: agent/src/vss_agents/agents/postprocessing/validators/url_validator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """URL validator to verify URLs are accessible.""" import logging import re from typing import Any import aiohttp from tenacity import AsyncRetrying from tenacity import retry_if_exception_type from tenacity import stop_after_attempt from tenacity import wait_random_exponential from vss_agents.agents.postprocessing.data_models import ValidatorResult from vss_agents.agents.postprocessing.validators.base import BaseValidator from vss_agents.utils.url_translation import rewrite_url_host logger = logging.getLogger(__name__) # 1) Tags with alt: or . We capture src= or href= (the URL). # Attribute order varies, so we have two patterns each: url first (group 1), or alt first (group 2 = url). # \\?" / \\?' handles optional backslash-escaped quotes produced by LLMs in JSON contexts. _Q = r"""\\?["']""" # matches an optional backslash followed by a single or double quote _VAL = r"""[^"'\\]+""" # URL value: excludes quotes and backslashes _ALTVAL = r"""[^"'\\]*""" # alt text value: same but may be empty TAG_ALT_SRC_PATTERN = re.compile( rf"<[a-zA-Z][^>]*\ssrc={_Q}({_VAL}){_Q}[^>]*\salt={_Q}({_ALTVAL}){_Q}[^>]*/?>", re.IGNORECASE, ) TAG_ALT_SRC_ORDER2 = re.compile( rf"<[a-zA-Z][^>]*\salt={_Q}({_ALTVAL}){_Q}[^>]*\ssrc={_Q}({_VAL}){_Q}[^>]*/?>", re.IGNORECASE, ) TAG_ALT_HREF_PATTERN = re.compile( rf"<[a-zA-Z][^>]*\shref={_Q}({_VAL}){_Q}[^>]*\salt={_Q}({_ALTVAL}){_Q}[^>]*/?>", re.IGNORECASE, ) TAG_ALT_HREF_ORDER2 = re.compile( rf"<[a-zA-Z][^>]*\salt={_Q}({_ALTVAL}){_Q}[^>]*\shref={_Q}({_VAL}){_Q}[^>]*/?>", re.IGNORECASE, ) # 2) Markdown: [text](url) and ![alt](url) # Assumes URLs do not contain nested parentheses MARKDOWN_LINK_URL_PATTERN = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") # Plain http(s) URLs (for other http(s) URLs not in tags or markdown) URL_PATTERN = re.compile(r'https?://[^\s<>"\']+', re.IGNORECASE) # Transient aiohttp errors worth retrying. _RETRYABLE_EXCEPTIONS = ( aiohttp.ClientConnectorError, aiohttp.ServerTimeoutError, aiohttp.ServerDisconnectedError, TimeoutError, ) _BACKOFF_SECONDS = 1.0 def _strip_url(url: str) -> str: """Strip trailing punctuation that might have been captured with the URL.""" return (url or "").strip().rstrip(".,;:!?)'\"\\") def extract_urls_from_tags_with_alt(text: str) -> list[str]: """Extract src= or href= (the URL) from any tag that has alt=. Generic: or alt='text'.""" urls: list[str] = [] for pattern in (TAG_ALT_SRC_PATTERN, TAG_ALT_HREF_PATTERN): for m in pattern.finditer(text): url = _strip_url(m.group(1)) if url: urls.append(url) for pattern in (TAG_ALT_SRC_ORDER2, TAG_ALT_HREF_ORDER2): for m in pattern.finditer(text): url = _strip_url(m.group(2)) # url is group 2 when alt comes first if url: urls.append(url) return urls def extract_urls_from_markdown_links(text: str) -> list[str]: """Extract URLs from Markdown [text](url) and ![alt](url).""" urls: list[str] = [] for m in MARKDOWN_LINK_URL_PATTERN.finditer(text): url = _strip_url(m.group(1)) if url: urls.append(url) return urls def is_valid_url(src: str) -> bool: """True if src starts with http:// or https:// (This function only checks scheme, not full URL accessibility).""" return bool(src and (src.lower().startswith("http://") or src.lower().startswith("https://"))) def extract_urls(text: str) -> list[str]: """Extract and dedupe http(s) URLs from text (used for other URLs not in tags/markdown).""" seen: set[str] = set() result: list[str] = [] for u in URL_PATTERN.findall(text): url = _strip_url(u) if url and url not in seen: seen.add(url) result.append(url) return result class URLValidator(BaseValidator): """Verify URLs are accessible.""" name = "url_validator" def __init__( self, internal_ip: str, timeout: float = 10.0, feedback_template: str = "", max_retries: int = 2, **kwargs: Any, # noqa: ARG002 ) -> None: """Initialize the URL validator. Args: internal_ip: Internal IP address (e.g. ``10.0.1.1``). The host in each URL is rewritten to this IP before validation so that accessibility checks always hit the internal endpoint. timeout: HTTP request timeout in seconds. feedback_template: Template for feedback message. Use {issues} placeholder. max_retries: Number of retries for transient HTTP errors per URL. """ super().__init__( feedback_template=feedback_template, ) self.timeout = aiohttp.ClientTimeout(total=timeout) self.max_retries = max_retries self.internal_ip = internal_ip async def validate(self, output: str, **kwargs: Any) -> ValidatorResult: # noqa: ARG002 """Check URLs from: (1) any tag with alt (src/href), (2) Markdown [text](url), (3) other plain URLs. All must be valid and accessible.""" issues: list[str] = [] seen: set[str] = set() urls_to_check_accessibility: list[str] = [] # 1-2) Tags with alt (src/href) and Markdown links: must be http(s) or fail; if valid, add to urls_to_check_accessibility url_sources = [ (extract_urls_from_tags_with_alt(output), "tag"), (extract_urls_from_markdown_links(output), "Markdown link"), ] for urls, source_label in url_sources: for url in urls: if url in seen: continue seen.add(url) if not is_valid_url(url): issues.append(url) logger.info(f"{self.name}: invalid URL in {source_label}: {url!r}") else: urls_to_check_accessibility.append(url) # 3) Other plain http(s) URLs not in the above (extract_urls already returns http(s) only) for url in extract_urls(output): if url not in seen: seen.add(url) urls_to_check_accessibility.append(url) if urls_to_check_accessibility: logger.info(f"{self.name}: checking {len(urls_to_check_accessibility)} URL(s) for accessibility") async with aiohttp.ClientSession(timeout=self.timeout) as session: for url in urls_to_check_accessibility: accessible = await self._validate_url(session, url) if accessible: logger.debug(f"{self.name}: PASSED for {url}") else: logger.info(f"{self.name}: FAILED (not accessible): {url}") issues.append(url) # Return with all issues (invalid URLs from tags/markdown + inaccessible URLs) return ValidatorResult(name=self.name, passed=len(issues) == 0, issues=issues) async def _validate_url(self, session: aiohttp.ClientSession, url: str) -> bool: """Check if URL is accessible, with retries on transient errors. When ``internal_ip`` is configured, the URL host is rewritten to this IP so that the request always reaches the internal service directly. """ if self.internal_ip: url = rewrite_url_host(url, self.internal_ip) logger.debug(f"{self.name}: rewritten URL for validation: {url}") if self.max_retries > 0: retrying = AsyncRetrying( retry=retry_if_exception_type(_RETRYABLE_EXCEPTIONS), stop=stop_after_attempt(self.max_retries + 1), wait=wait_random_exponential(multiplier=_BACKOFF_SECONDS, max=_BACKOFF_SECONDS * 8), reraise=True, ) async for attempt in retrying: with attempt: return await self._try_request(session, url) return await self._try_request(session, url) async def _try_request(self, session: aiohttp.ClientSession, url: str) -> bool: """HEAD first, fall back to GET on 404/405/501 or transport error.""" try: async with session.head(url, allow_redirects=True) as resp: if resp.status < 400: return True if resp.status in (404, 405, 501): logger.debug(f"HEAD failed for {url} (HTTP {resp.status}), trying GET") else: logger.info(f"URL failed: {url} (HTTP {resp.status})") return False except Exception as e: logger.debug(f"HEAD failed for {url} ({e}), trying GET") try: async with session.get(url, allow_redirects=True) as resp: if resp.status < 400: return True logger.info(f"URL failed: {url} (HTTP {resp.status})") return False except Exception as e: logger.info(f"URL failed: {url} ({e})") return False ================================================ FILE: agent/src/vss_agents/agents/register.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Import agents to trigger registration from . import critic_agent from . import multi_report_agent from . import report_agent from . import search_agent from . import top_agent __all__ = [ "critic_agent", "multi_report_agent", "report_agent", "search_agent", "top_agent", ] ================================================ FILE: agent/src/vss_agents/agents/report_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Single Incident Report Agent - Deterministic tool-calling workflow. This agent generates detailed reports for single incidents. No LLM is used for decision-making; it follows a predetermined tool sequence: 1. Get most recent incident from video analytics 2. Generate detailed report with video analysis For multiple incidents, use multi_report_agent instead. For long videos, use lvs_agent instead. """ from collections.abc import AsyncGenerator from datetime import datetime import json import logging import time from typing import Any from typing import Literal from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.agents.data_models import AgentMessageChunk from vss_agents.agents.data_models import AgentMessageChunkType from vss_agents.agents.data_models import AgentOutput logger = logging.getLogger(__name__) # ========== REPORT AGENT MODELS ========== class ReportAgentInput(BaseModel): """ Input for the deterministic Report Agent (Single Incident). This agent handles detailed single incident analysis with Video Analytics MCP. For multiple incidents, use multi_report_agent instead. """ # Time range parameters start_time: datetime | None = Field(default=None, description="Start time for incident search.") end_time: datetime | None = Field(default=None, description="End time for incident search.") # Incident/source identifiers incident_id: str | None = Field( default=None, description="Specific incident ID. If provided, other search params are ignored.", ) source: str | None = Field(default=None, description="Source to filter incidents (sensor ID or place/city name).") source_type: Literal["sensor", "place"] | None = Field( default=None, description="Type of the source. Must be 'sensor' or 'place'. Required if source is provided." ) vlm_reasoning: bool | None = Field( default=None, description="Enable VLM reasoning mode for video analysis. If None, uses video_understanding config default.", ) llm_reasoning: bool | None = Field( default=None, description="Enable LLM reasoning mode for report generation. If None, uses workflow config default.", ) class VideoReportAgentInput(BaseModel): """ Input for the Video(uploaded) Report Agent (Mode 3). This mode works without Video Analytics MCP - directly analyzes uploaded videos from VST. No incident database required. Always analyzes the full video. """ sensor_id: str = Field( ..., description="VST sensor ID for video retrieval", ) user_query: str = Field( "Generate a detailed report of the video.", description="The user's question or analysis request for this video", ) vlm_reasoning: bool | None = Field( default=None, description="Enable VLM reasoning mode for video analysis. If None, uses video_understanding config default.", ) class ReportAgentConfig(FunctionBaseConfig, name="report_agent"): """Config for the single incident report agent.""" # Tool references - Video Analytics MCP tools are optional (if None, runs in Mode 3/Video(uploaded) Report mode) get_incidents_tool: FunctionRef | None = Field( default=None, description="Tool to get incidents from video analytics (e.g., video_analytics_mcp.video_analytics.get_incidents). If None, runs in Mode 3 (Video(uploaded) Report mode)", ) get_incident_tool: FunctionRef | None = Field( default=None, description="Tool to get a single incident by ID (e.g., video_analytics_mcp.video_analytics.get_incident). If None, runs in Mode 3 (Video(uploaded) Report mode)", ) template_report_tool: FunctionRef | None = Field( default=None, description="Tool to generate detailed single incident report (e.g., template_report_gen). Used for Video Analytics MCP mode.", ) video_report_tool: FunctionRef | None = Field( default=None, description="Tool to generate Video(uploaded) video analysis reports (e.g., video_report_gen). Used for Video(uploaded) Report mode.", ) @register_function(config_type=ReportAgentConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def report_agent(config: ReportAgentConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Deterministic report agent with automatic mode detection. Modes: - Video Analytics MCP mode: Incident-based reports with Elasticsearch (when Video Analytics MCP tools configured) - SINGLE_INCIDENT: get_incidents(max=1) → template_report_gen - MULTI_INCIDENT: get_incidents(max=N) → template_report_gen - Video(uploaded) Report mode: Direct video analysis without Video Analytics MCP (when Video Analytics MCP tools not configured) """ # === MODE DETECTION === # Check if Video Analytics MCP tools are configured va_mcp_enabled = config.get_incidents_tool is not None and config.get_incident_tool is not None if va_mcp_enabled: logger.info("Report Agent running in Mode 1 (Video Analytics MCP enabled)") else: logger.info("Report Agent running in Mode 3 (Video(uploaded) Report mode - no Video Analytics MCP)") # === LOAD TOOLS CONDITIONALLY === get_incidents_tool = None get_incident_tool = None template_report_tool = None video_report_tool = None if va_mcp_enabled: logger.info("Loading Video Analytics MCP tools") if not config.get_incidents_tool: raise ValueError("get_incidents_tool must be configured for Video Analytics MCP mode") if not config.get_incident_tool: raise ValueError("get_incident_tool must be configured for Video Analytics MCP mode") if not config.template_report_tool: raise ValueError("template_report_tool must be configured for Video Analytics MCP mode") get_incidents_tool = await builder.get_tool(config.get_incidents_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) get_incident_tool = await builder.get_tool(config.get_incident_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) template_report_tool = await builder.get_tool( config.template_report_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN ) logger.info("Video Analytics MCP tools loaded successfully") else: logger.info("Loading Video(uploaded) Report tools") if not config.video_report_tool: raise ValueError( "video_report_tool must be configured for Video(uploaded) Report mode. Otherwise Video Analytics MCP tools must be configured." ) video_report_tool = await builder.get_tool(config.video_report_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) logger.info("Video(uploaded) Report tools loaded successfully") logger.info( f"Report Agent initialized ({'Video Analytics MCP mode' if va_mcp_enabled else 'Video(uploaded) Report mode'})" ) # Define mode-specific execution functions if va_mcp_enabled: async def _execute_report_va_mcp( source: str | None = None, source_type: Literal["sensor", "place"] | None = None, start_time: datetime | None = None, end_time: datetime | None = None, incident_id: str | None = None, vlm_reasoning: bool | None = None, llm_reasoning: bool | None = None, ) -> AsyncGenerator[AgentMessageChunk]: """ Execute single incident report generation. Args: source: Source to filter incidents (sensor ID or place/city name) source_type: Type of the source ('sensor' or 'place') start_time: Start time for incident search end_time: End time for incident search incident_id: Specific incident ID Yields: AgentMessageChunk objects for tool calls and final result """ logger.info("Executing incident-based single incident report") execution_start_time = time.time() # Construct Mode 1 input report_input = ReportAgentInput( source=source, source_type=source_type, start_time=start_time, end_time=end_time, incident_id=incident_id, vlm_reasoning=vlm_reasoning, llm_reasoning=llm_reasoning, ) try: async for chunk in _handle_single_incident(report_input): yield chunk except (ValueError, KeyError, AttributeError, json.JSONDecodeError) as e: logger.exception("Report Agent: Failed to execute incident report") execution_time_ms = int((time.time() - execution_start_time) * 1000) error_output = AgentOutput( messages=[f"Report Agent: Error generating incident report: {e!s}"], status="error", error_message=f"Report Agent: Failed to generate incident report: {e!s}", metadata={ "generation_time_ms": execution_time_ms, "report_type": "single_incident", }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=error_output.model_dump_json()) except Exception: logger.exception("Report Agent: Unexpected error in incident report execution") execution_time_ms = int((time.time() - execution_start_time) * 1000) error_output = AgentOutput( messages=["Report Agent: Unexpected error generating incident report"], status="error", error_message="Report Agent: Unexpected error in incident report execution", metadata={ "generation_time_ms": execution_time_ms, "report_type": "single_incident", }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=error_output.model_dump_json()) else: # Video(uploaded) Report mode (no Video Analytics MCP) async def _execute_report_video( sensor_id: str, user_query: str, vlm_reasoning: bool | None = None, ) -> AsyncGenerator[AgentMessageChunk]: """ Execute Video(uploaded) Report generation (video-based, no Video Analytics MCP). Args: sensor_id: VST sensor ID (filename of uploaded video) user_query: The user's question or analysis request Returns: AgentMessageChunk objects for tool calls and final result """ logger.info("Executing Video(uploaded) Report (video-based)") execution_start_time = time.time() # Construct Mode 3 input video_report_input = VideoReportAgentInput( sensor_id=sensor_id, user_query=user_query, vlm_reasoning=vlm_reasoning, ) try: async for chunk in _video_report_agent(video_report_input): yield chunk except (ValueError, KeyError, AttributeError) as e: logger.exception("Report Agent: Failed to execute direct video analysis report") execution_time_ms = int((time.time() - execution_start_time) * 1000) # Check if this is a websocket connection error error_str = str(e) if ( "No human prompt callback was registered" in error_str or "Unable to handle requested prompt" in error_str ): user_message = ( "Could not start human in the loop workflow over websocket. " "Please check that websocket connection is enabled in the UI and that the IP of agent " "is set correctly in the settings panel from the left lower side." ) error_message = f"Report Agent: Websocket connection error - {user_message}" else: user_message = f"Report Agent: Error generating video analysis report: {error_str}" error_message = f"Report Agent: Failed to generate video analysis report: {error_str}" error_output = AgentOutput( messages=[user_message], status="error", error_message=error_message, metadata={ "generation_time_ms": execution_time_ms, "report_type": "video_report", "mode": "video(uploaded) report", }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=error_output.model_dump_json()) except Exception: logger.exception("Report Agent: Unexpected error in direct video analysis report execution") execution_time_ms = int((time.time() - execution_start_time) * 1000) error_output = AgentOutput( messages=["Report Agent: Unexpected error generating video analysis report"], status="error", error_message="Report Agent: Unexpected error in video analysis report execution", metadata={ "generation_time_ms": execution_time_ms, "report_type": "video_report", "mode": "video(uploaded) report", }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=error_output.model_dump_json()) async def _handle_single_incident(report_input: ReportAgentInput) -> AsyncGenerator[AgentMessageChunk]: """ Mode 1: Get incident and generate detailed report. Tool sequence: 1. get_incidents(max_count=1)/get_incident → get most recent incident or get a specific incident by ID 2. template_report_gen(incident_id) → generate detailed report """ # These tools are guaranteed to be set when va_mcp_enabled is True assert get_incident_tool is not None assert get_incidents_tool is not None assert template_report_tool is not None logger.info("Mode 1: Single incident report") incident = None # If incident_id is provided, get specific incident if report_input.incident_id: logger.info(f"Getting incident by ID: {report_input.incident_id}") tool_call_args = {"id": report_input.incident_id, "includes": ["objectIds", "info"]} yield AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content=f"Tool: get_incident\nArgs: {tool_call_args}" ) incident_result = await get_incident_tool.ainvoke(tool_call_args) if isinstance(incident_result, str): try: incident = json.loads(incident_result) except json.JSONDecodeError: logger.exception("Report Agent: Failed to parse get_incident response as JSON: %s", incident_result) error_output = AgentOutput( messages=[ f"Report Agent: Unable to parse incident data for ID '{report_input.incident_id}'. The Video Analytics service returned an invalid response." ], status="error", error_message="Report Agent: Failed to parse Video Analytics MCP tool response", ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=error_output.model_dump_json()) return else: incident = incident_result if not incident: no_incident_output = AgentOutput( messages=[f"No incident found with ID '{report_input.incident_id}'."], status="success", metadata={"incident_id": report_input.incident_id}, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=no_incident_output.model_dump_json()) return else: get_incidents_params = { "max_count": 1, "includes": ["objectIds", "info"], "source": report_input.source, "source_type": report_input.source_type, "start_time": report_input.start_time.strftime("%Y-%m-%dT%H:%M:%S.000Z") if report_input.start_time else None, "end_time": report_input.end_time.strftime("%Y-%m-%dT%H:%M:%S.000Z") if report_input.end_time else None, } logger.info(f"Getting incidents with params: {get_incidents_params}") yield AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content=f"Tool: get_incidents\nArgs: {get_incidents_params}" ) incidents_result = await get_incidents_tool.ainvoke(get_incidents_params) if isinstance(incidents_result, str): try: parsed_result = json.loads(incidents_result) incidents = parsed_result.get("incidents", []) except json.JSONDecodeError: logger.exception( "Report Agent: Failed to parse get_incidents response as JSON: %s", incidents_result ) error_output = AgentOutput( messages=[ "Report Agent: Unable to parse incidents data. The Video Analytics service returned an invalid response." ], status="error", error_message="Report Agent: Failed to parse Video Analytics MCP tool response", ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=error_output.model_dump_json()) return else: # Assume it's already parsed (tuple format) incidents, _ = incidents_result if not incidents: no_incidents_output = AgentOutput( messages=["No incidents found with the specified criteria."], status="success", metadata={"incident_count": 0}, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=no_incidents_output.model_dump_json()) return incident = incidents[0] # Handle both "Id" and "id" field names incident_id = incident.get("Id") or incident.get("id") or "unknown" logger.info(f"Found incident: {incident_id}") # Step 2: Generate detailed report logger.info("Generating detailed report") report_tool_args = { "incident_id": incident_id, "alert_sensor_id": incident.get("sensorId"), "alert_from_timestamp": incident.get("timestamp"), "alert_to_timestamp": incident.get("end"), "alert_metadata": incident, # Pass the entire incident object as metadata "vlm_reasoning": report_input.vlm_reasoning, # Pass VLM reasoning flag "llm_reasoning": report_input.llm_reasoning, # Pass LLM reasoning flag } yield AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content=f"Tool: template_report_gen\nArgs: {{'incident_id': '{incident_id}'}}", ) report_result = await template_report_tool.ainvoke(report_tool_args) logger.info("Single incident report generated successfully") side_effects = {} if hasattr(report_result, "http_url") or hasattr(report_result, "pdf_url"): downloads = ["**Report Downloads:**"] if hasattr(report_result, "http_url") and report_result.http_url: downloads.append(f"- [Markdown Report]({report_result.http_url})") if hasattr(report_result, "pdf_url") and report_result.pdf_url: downloads.append(f"- [PDF Report]({report_result.pdf_url})") side_effects["report_downloads"] = "\n".join(downloads) + "\n" if hasattr(report_result, "image_url") or hasattr(report_result, "video_url"): media = ["**Media:**"] if hasattr(report_result, "image_url") and report_result.image_url: media.append(f"- ![Incident Snapshot]({report_result.image_url})") if hasattr(report_result, "video_url") and report_result.video_url: media.append(f"- [Incident Video]({report_result.video_url})") side_effects["media"] = "\n".join(media) + "\n" agent_output = AgentOutput( messages=[f"Report generated successfully for incident {incident_id}"], side_effects=side_effects, status="success", metadata={ "incident_count": 1, "incident_id": incident_id, "sensor_id": incident.get("sensorId"), "report_type": "single_incident", }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=agent_output.model_dump_json()) async def _video_report_agent(video_report_input: VideoReportAgentInput) -> AsyncGenerator[AgentMessageChunk]: """ Video(uploaded) Report mode: Direct video analysis without Video Analytics MCP. This mode works with uploaded videos from VST. Always analyzes the full video (assumed to be < 2 mins). Delegates to video_report_gen tool which handles: 1. VLM prompt sanitization (removes SOM markers) 2. Video analysis via video_understanding 3. Report formatting with optional template 4. Media URL fetching Args: video_report_input: Video(uploaded) Report mode-specific input with sensor_id and user_query Returns: AgentOutput with video analysis and media URLs """ # This tool is guaranteed to be set when va_mcp_enabled is False assert video_report_tool is not None logger.info(f"Video(uploaded) Report mode: Analyzing uploaded video '{video_report_input.sensor_id}'") try: # Call the Video(uploaded) Report generation tool tool_input: dict[str, Any] = { "sensor_id": video_report_input.sensor_id, "user_query": video_report_input.user_query, } # Add vlm_reasoning if provided if video_report_input.vlm_reasoning is not None: tool_input["vlm_reasoning"] = video_report_input.vlm_reasoning report_result = await video_report_tool.ainvoke(tool_input) except Exception as e: logger.exception( f"Report Agent: Video analysis report generation failed for video '{video_report_input.sensor_id}': {e}" ) raise ValueError( f"Report Agent: Failed to generate video analysis report for video '{video_report_input.sensor_id}': {e}" ) from e # Check if report was cancelled (no http_url means no report was generated) if not report_result.http_url: logger.info(f"Video report cancelled for '{video_report_input.sensor_id}'") agent_output = AgentOutput( messages=[report_result.summary or "Report generation was cancelled."], side_effects={}, status="success", metadata={"sensor_id": video_report_input.sensor_id}, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=agent_output.model_dump_json()) return logger.info(f"Video(uploaded) report generated successfully for '{video_report_input.sensor_id}'") # Format output side_effects = {} downloads = ["**Report Downloads:**"] downloads.append(f"- [Markdown Report]({report_result.http_url})") if report_result.pdf_url: downloads.append(f"- [PDF Report]({report_result.pdf_url})") side_effects["report_downloads"] = "\n".join(downloads) + "\n" if report_result.video_url: media = ["**Media:**"] media.append(f"- [Video Playback]({report_result.video_url})") side_effects["media"] = "\n".join(media) + "\n" # Build messages list messages = [ f"Video analysis complete for '{video_report_input.sensor_id}'.\n", f"Query: {video_report_input.user_query}.\n", ] # Add HITL prompts if available (from LVS) if hasattr(report_result, "hitl_prompts") and report_result.hitl_prompts: hitl = report_result.hitl_prompts messages.append("\n**Prompts:**\n") if hitl.get("scenario"): messages.append(f"- Scenario: {hitl['scenario']}\n") if hitl.get("events"): events_str = ", ".join(hitl["events"]) messages.append(f"- Events of interest: {events_str}\n") if hitl.get("objects_of_interest"): objects_str = ", ".join(hitl["objects_of_interest"]) messages.append(f"- Objects of interest: {objects_str}\n") messages.append("\n") # Add empty line for spacing messages.append(report_result.summary) agent_output = AgentOutput( messages=messages, side_effects=side_effects, status="success", metadata={ "sensor_id": video_report_input.sensor_id, "report_type": "video_report", "file_size": report_result.file_size, "pdf_file_size": report_result.pdf_file_size, }, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=agent_output.model_dump_json()) # Register the function with dynamic schema based on Video Analytics MCP availability if va_mcp_enabled: yield FunctionInfo.create( stream_fn=_execute_report_va_mcp, description=( "Generate detailed single incident reports using deterministic tool sequences. " "Fetches the most recent incident and generates a comprehensive report with video analysis. " "For multiple incidents, use multi_report_agent instead. " "Returns AgentOutput with messages, side_effects (reports, URLs), and metadata." ), input_schema=ReportAgentInput, stream_output_schema=AgentMessageChunk, ) else: # Video(uploaded) Report mode yield FunctionInfo.create( stream_fn=_execute_report_video, description=( "Generate video analysis reports for uploaded videos without requiring incident database. " "Analyzes full videos directly from VST based on sensor_id (filename). " "Returns AgentOutput with messages, side_effects (reports, URLs), and metadata." ), input_schema=VideoReportAgentInput, stream_output_schema=AgentMessageChunk, ) ================================================ FILE: agent/src/vss_agents/agents/search_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Search Agent - Streaming search with agent-think visibility. This agent implements the full search workflow with streaming and three execution paths: - Path 1: Attribute-only search (if has_action=False and attributes exist) - Query decomposition → Attribute search - Path 2: Embed-only search (if no attributes) - Query decomposition → Embed search - Path 3: Fusion search (if has_action=True and attributes exist) - Query decomposition → Embed search → Fusion reranking (with confidence threshold check) All paths yield AgentMessageChunk for real-time visibility. """ from collections.abc import AsyncGenerator import json import logging from typing import Any from typing import Literal from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.api_server import ChatRequest from nat.data_models.api_server import ChatResponse from nat.data_models.api_server import ChatResponseChunk from nat.data_models.api_server import Usage from nat.data_models.component_ref import FunctionRef from nat.data_models.component_ref import LLMRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.agents.data_models import AgentMessageChunk from vss_agents.agents.data_models import AgentMessageChunkType from vss_agents.agents.data_models import AgentOutput from vss_agents.tools.search import SearchInput from vss_agents.tools.search import SearchOutput from vss_agents.tools.search import SearchResult from vss_agents.tools.search import execute_core_search logger = logging.getLogger(__name__) def _to_search_results(raw: list) -> list[SearchResult]: """Convert raw results (embed/attribute) to SearchResult schema. Used by both sync and streaming.""" out = [] for r in raw: if isinstance(r, SearchResult): out.append(r) elif hasattr(r, "model_dump"): d = r.model_dump() d.setdefault("similarity", d.pop("similarity_score", 0.0)) d.setdefault("object_ids", []) out.append(SearchResult(**d)) elif isinstance(r, dict): d = dict(r) d.setdefault("similarity", d.pop("similarity_score", 0.0)) d.setdefault("object_ids", []) out.append(SearchResult(**d)) else: continue return out class SearchAgentInput(BaseModel): """Input for search agent.""" query: str = Field(description="Natural language search query") agent_mode: bool = Field(default=True, description="Enable query decomposition") use_attribute_search: bool | None = Field( default=None, description="Enable fusion reranking with attribute search (overrides config if provided)" ) max_results: int = Field(default=5, description="Maximum number of results to return") top_k: int | None = Field(default=None, description="Override top_k for embed search") start_time: str | None = Field(default=None, description="Start time filter (ISO format)") end_time: str | None = Field(default=None, description="End time filter (ISO format)") source_type: Literal["video_file", "rtsp"] = Field( default="video_file", description="Type of video source: 'video_file' for uploaded videos, 'rtsp' for live/camera streams", ) use_critic: bool = Field(default=True, description="Whether to verify search results with VLM critic agent") class SearchAgentConfig(FunctionBaseConfig, name="search_agent"): """Config for search agent.""" # Tool references - we'll call these directly embed_search_tool: FunctionRef = Field(description="Embed search tool reference") attribute_search_tool: FunctionRef | None = Field( default=None, description="Attribute search tool for fusion (optional)" ) agent_mode_llm: LLMRef | None = Field( default=None, description="LLM for query decomposition (required if agent_mode=True)" ) use_attribute_search: bool = Field( default=False, description="If True and attribute_search_tool is configured, performs multi-attribute object-level search using extracted attributes from query decomposition. Requires agent_mode=True. (internal config, not exposed to user)", ) default_max_results: int = Field( default=10, description="Maximum number of results to return. Used as the default top_k when not specified and as a cap when top_k is too high.", ) # Config fields needed for execute_core_search (matching SearchConfig) embed_confidence_threshold: float = Field( default=0.1, description="Minimum embed search similarity threshold. If all embed results are below this threshold, fallback to attribute-only search (if attributes exist).", ) vst_internal_url: str = Field( ..., description="The internal VST URL for stream_id to sensor_id conversion in fusion reranking.", ) fusion_method: Literal["weighted_linear", "rrf", "rrf_with_attribute_rank"] = Field( default="rrf", description="Fusion method: 'weighted_linear' for weighted linear fusion, 'rrf' for Reciprocal Rank Fusion using embed rank, 'rrf_with_attribute_rank' for RRF using both embed and attribute ranks", ) w_attribute: float = Field( default=0.55, description="Weight for attribute score in weighted linear fusion (default: 0.55)", ) w_embed: float = Field( default=0.35, description="Weight for embed score in weighted linear fusion (default: 0.35)", ) rrf_k: int = Field( default=60, description="RRF constant k for Reciprocal Rank Fusion (default: 60, only used for RRF)", ) rrf_w: float = Field( default=0.5, description="RRF weight w for attribute cosine similarity in Reciprocal Rank Fusion (default: 0.5, only used for RRF)", ) critic_agent: FunctionRef | None = Field( default=None, description="Optional critic agent to verify search results with VLM" ) enable_critic: bool = Field( default=False, description="Configuration flag to enable/disable critic agent at a global level.", ) search_max_iterations: int = Field( default=1, ge=1, description="""Maximum number of search iterations when refining search results with critic agent. Note, high max iterations can run for a long time. Default is 1.""", ) # ===== Presentation converters (moved from embed_search.py) ===== # These operate on SearchOutput (from search.py) instead of VisionLLM. def _to_incidents_output(search_output: SearchOutput) -> str: """Format SearchOutput results as incidents JSON wrapped in tags.""" incidents = [] for result in search_output.data: try: incident = { "Alert Details": { "Alert Triggered": result.video_name, "video_description": result.description, "similarity_score": round(result.similarity, 2), "description": result.description, }, "Clip Information": { "Timestamp": result.start_time, "video_id": result.video_name, "start_time": result.start_time, "end_time": result.end_time, }, } incidents.append(incident) except Exception as e: logger.error(f"Error parsing search result: {e}") continue incidents_json = {"incidents": incidents} json_string = json.dumps(incidents_json, indent=2) return f"\n{json_string}\n" def _helper_markdown_bullet_list(search_output: SearchOutput) -> str: """Convert SearchOutput to markdown bullet list.""" markdown = "```markdown\n" for result in search_output.data: try: markdown += ( f"- **Video ID:** `{result.video_name}`\n" f" * Similarity Score: **{result.similarity:.2f}**\n" f" * Description: {result.description}\n" f" * Start Time: {result.start_time}\n" f" * End Time: {result.end_time}\n" f" * Sensor ID: {result.sensor_id}\n" f" * Timestamp: {result.start_time}\n\n" ) except Exception as e: logger.error(f"Error formatting search result: {e}") continue markdown += "```" return markdown def _to_chat_response(search_output: SearchOutput) -> ChatResponse: """Convert SearchOutput to ChatResponse.""" incidents = _to_incidents_output(search_output) return ChatResponse.from_string(incidents, usage=Usage()) def _to_chat_response_chunk(search_output: SearchOutput) -> ChatResponseChunk: """Convert SearchOutput to ChatResponseChunk.""" incidents = _to_incidents_output(search_output) return ChatResponseChunk.from_string(incidents) @register_function(config_type=SearchAgentConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def search_agent(config: SearchAgentConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Search agent with streaming support - implements full search workflow. Calls search components directly (decompose_query, embed_search, attribute_search) and streams intermediate steps as AgentMessageChunk. """ # Load function references (for execute_core_search) attribute_search_fn = None # Function reference for fusion_search_rerank vst_internal_url = None # For sensor-id conversion in fusion reranking if config.attribute_search_tool: # Get function reference for fusion reranker (reuses search.py logic) attribute_search_fn = await builder.get_function(config.attribute_search_tool) # Get VST URL from attribute_search config for stream_id to sensor_id conversion try: attr_search_config = await builder.get_config(config.attribute_search_tool) if hasattr(attr_search_config, "vst_internal_url"): vst_internal_url = attr_search_config.vst_internal_url logger.info(f"Retrieved vst_internal_url from attribute_search config: {vst_internal_url}") else: logger.warning("attribute_search config does not have vst_internal_url attribute") except Exception as e: logger.warning(f"Could not get VST URL from attribute_search config: {e}") agent_llm = None if config.agent_mode_llm: agent_llm = await builder.get_llm(config.agent_mode_llm, wrapper_type=LLMFrameworkEnum.LANGCHAIN) # Get critic agent if configured critic_agent = None if config.critic_agent: critic_agent = await builder.get_function(config.critic_agent) logger.info("Search agent initialized with direct tool references") async def _execute_search(search_agent_input: SearchAgentInput) -> SearchOutput: """Non-streaming search execution. Returns SearchOutput directly.""" # Convert SearchAgentInput to SearchInput from vss_agents.utils.time_convert import iso8601_to_datetime timestamp_start = None timestamp_end = None if search_agent_input.start_time: try: timestamp_start = iso8601_to_datetime(search_agent_input.start_time) except Exception as e: logger.warning(f"Failed to parse start_time: {e}") if search_agent_input.end_time: try: timestamp_end = iso8601_to_datetime(search_agent_input.end_time) except Exception as e: logger.warning(f"Failed to parse end_time: {e}") # top_k = input.top_k if input.top_k else default_max_result # User's top_k overrides default_max_result (no capping) top_k = search_agent_input.top_k if search_agent_input.top_k is not None else config.default_max_results search_input = SearchInput( query=search_agent_input.query, source_type=search_agent_input.source_type, top_k=top_k, agent_mode=search_agent_input.agent_mode, timestamp_start=timestamp_start, timestamp_end=timestamp_end, use_critic=search_agent_input.use_critic, ) # Get embed_search function reference embed_search_fn = await builder.get_function(config.embed_search_tool) # Use shared core search function (async generator, collect all progress and return final result) search_output = None async for update in execute_core_search( search_input=search_input, embed_search=embed_search_fn, agent_llm=agent_llm, config=config, builder=builder, attribute_search_fn=attribute_search_fn, critic_agent=critic_agent, ): if isinstance(update, SearchOutput): search_output = update search_output = search_output or SearchOutput(data=[]) return search_output def _get_result_name(result: Any) -> str: """Helper to extract video name from result (dict or object).""" if isinstance(result, dict): name = result.get("video_name") or result.get("video_file") return str(name) if name is not None else "unknown" else: name = getattr(result, "video_name", None) or getattr(result, "video_file", None) return str(name) if name is not None else "unknown" async def _execute_search_stream( search_agent_input: SearchAgentInput, ) -> AsyncGenerator[AgentMessageChunk]: """ Execute search with full streaming - implements three execution paths using shared core search function. Path 1: Attribute-only search (if has_action=False and attributes exist) Path 2: Embed-only search (if no attributes) Path 3: Fusion search (if has_action=True and attributes exist, with confidence threshold check) """ query = search_agent_input.query agent_mode = search_agent_input.agent_mode # Use input value if provided, otherwise use config default use_attribute_search_flag = ( search_agent_input.use_attribute_search if search_agent_input.use_attribute_search is not None else config.use_attribute_search ) max_results = search_agent_input.max_results top_k = search_agent_input.top_k start_time = search_agent_input.start_time end_time = search_agent_input.end_time source_type = search_agent_input.source_type logger.info(f"Search agent executing: {search_agent_input.model_dump_json()}") # Convert SearchAgentInput to SearchInput from vss_agents.utils.time_convert import iso8601_to_datetime timestamp_start = None timestamp_end = None if start_time: try: timestamp_start = iso8601_to_datetime(start_time) except Exception as e: logger.warning(f"Failed to parse start_time: {e}") if end_time: try: timestamp_end = iso8601_to_datetime(end_time) except Exception as e: logger.warning(f"Failed to parse end_time: {e}") # top_k = input.top_k if input.top_k else default_max_result # User's top_k overrides default_max_result (no capping) top_k = top_k if top_k is not None else config.default_max_results search_input = SearchInput( query=query, source_type=source_type, top_k=top_k, agent_mode=agent_mode, timestamp_start=timestamp_start, timestamp_end=timestamp_end, use_critic=search_agent_input.use_critic, ) # Get embed_search function reference embed_search_fn = await builder.get_function(config.embed_search_tool) try: # Use shared core search function (async generator) - yield progress updates in real-time search_output = None async for update in execute_core_search( search_input=search_input, embed_search=embed_search_fn, agent_llm=agent_llm, config=config, builder=builder, attribute_search_fn=attribute_search_fn, critic_agent=critic_agent, ): if isinstance(update, AgentMessageChunk): # Forward progress updates directly yield update elif isinstance(update, SearchOutput): search_output = update if search_output is None: search_output = SearchOutput(data=[]) # Note: execute_core_search already caps results to original_top_k, so no additional capping needed final_results = search_output.data result_count = len(final_results) # Build SearchOutput-compatible JSON results_dicts = [r.model_dump() for r in final_results] search_dict = {"data": results_dicts} # Format results for display if result_count > 0: summary = f"Found {result_count} matching video{'s' if result_count != 1 else ''}" search_result_json = json.dumps(search_dict, indent=2) messages = [summary, "\n\n**Search API result (JSON):**\n```json\n" + search_result_json + "\n```"] output = AgentOutput( messages=messages, side_effects={ "search_results": search_dict, "result_count": result_count, }, metadata={ "query": query, "agent_mode": agent_mode, "fusion_enabled": use_attribute_search_flag, "max_results": max_results, "filters": ( { "start_time": start_time, "end_time": end_time, } if (start_time or end_time) else None ), }, status="success", ) else: search_dict = {"data": []} search_result_json = json.dumps(search_dict, indent=2) output = AgentOutput( messages=[ f"No videos found matching: '{query}'", "\n\n**Search API result (JSON):**\n```json\n" + search_result_json + "\n```", ], side_effects={"search_results": search_dict}, metadata={"query": query}, status="success", ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=output.model_dump_json()) except Exception as e: logger.error(f"Search failed: {e}", exc_info=True) yield AgentMessageChunk(type=AgentMessageChunkType.ERROR, content=f"Search failed: {e!s}") output = AgentOutput( messages=["Search failed due to an error"], status="error", error_message=str(e), metadata={"query": query}, ) yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=output.model_dump_json()) # Input converters for search_agent def _str_input_converter(input: str) -> SearchAgentInput: return SearchAgentInput.model_validate_json(input) def _chat_request_input_converter(request: ChatRequest) -> SearchAgentInput: return SearchAgentInput.model_validate_json(request.messages[-1].content) # Register the agent yield FunctionInfo.create( single_fn=_execute_search, stream_fn=_execute_search_stream, input_schema=SearchAgentInput, single_output_schema=SearchOutput, stream_output_schema=AgentMessageChunk, converters=[ _str_input_converter, _chat_request_input_converter, _to_chat_response, _to_chat_response_chunk, _helper_markdown_bullet_list, ], ) ================================================ FILE: agent/src/vss_agents/agents/top_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio from collections.abc import AsyncGenerator from collections.abc import Hashable import copy from datetime import UTC from datetime import datetime import json import logging import re import time from typing import Any from typing import cast from typing import override from uuid import uuid4 from langchain_core.callbacks.base import BaseCallbackHandler from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage from langchain_core.messages import BaseMessage from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage from langchain_core.messages import ToolMessage from langchain_core.prompts import ChatPromptTemplate from langchain_core.prompts import MessagesPlaceholder from langchain_core.runnables import Runnable from langchain_core.runnables.config import RunnableConfig from langchain_core.tools import BaseTool from langgraph.checkpoint.memory import InMemorySaver from langgraph.config import get_stream_writer from langgraph.graph import StateGraph from langgraph.graph.state import CompiledStateGraph from nat.builder.builder import Builder from nat.builder.context import Context from nat.builder.context import ContextState from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.api_server import ChatRequest from nat.data_models.api_server import ChatRequestOrMessage from nat.data_models.api_server import Message from nat.data_models.component_ref import FunctionRef from nat.data_models.component_ref import LLMRef from nat.data_models.function import FunctionBaseConfig from nat.data_models.intermediate_step import IntermediateStepPayload from nat.data_models.intermediate_step import IntermediateStepType from nat.data_models.intermediate_step import StreamEventData from nat.data_models.intermediate_step import TokenUsageBaseModel from nat.data_models.intermediate_step import TraceMetadata from nat.data_models.intermediate_step import UsageInfo from nat.utils.type_converter import GlobalTypeConverter from pydantic import BaseModel from pydantic import Field from vss_agents.agents.data_models import AgentDecision from vss_agents.agents.data_models import AgentMessageChunk from vss_agents.agents.data_models import AgentMessageChunkType from vss_agents.agents.data_models import AgentOutput from vss_agents.agents.postprocessing import POSTPROCESSING_FEEDBACK_MARKER from vss_agents.agents.postprocessing import PostprocessingConfig from vss_agents.agents.postprocessing import PostprocessingNode from vss_agents.utils.asyncmixin import AsyncMixin from vss_agents.utils.reasoning_parsing import parse_reasoning_content from vss_agents.utils.reasoning_utils import get_llm_reasoning_bind_kwargs from vss_agents.utils.reasoning_utils import get_thinking_tag logger = logging.getLogger(__name__) PLAN_CLARIFY_PREFIX = "[USER]" TOOL_NOT_FOUND_ERROR_MESSAGE = "There is no tool named {tool_name}. Tool must be one of {tools}." NO_INPUT_ERROR_MESSAGE = "No human input received to the agent, Please ask a valid question." EMPTY_MESSAGES_ERROR = 'No input received in state: "current_message"' EMPTY_SCRATCHPAD_ERROR = 'No tool input received in state: "agent_scratchpad"' _TOOL_RESULTS_DELIMITER = "\n\n---\n### Latest Tool Results\n" class TopAgentRequest(ChatRequestOrMessage): """Extended ChatRequestOrMessage with reasoning parameters.""" llm_reasoning: bool | None = Field(default=None, description="Enable LLM reasoning mode") vlm_reasoning: bool | None = Field(default=None, description="Enable VLM reasoning mode") search_source_type: str = Field( default="video_file", description="Video source type for search: 'video_file' or 'rtsp'" ) def _extract_text_content(message: "Message") -> dict: """ Extract text content from a NAT Message for LangChain compatibility. NAT Message.content can be: - str: use directly - list[UserContent]: extract text from TextContent items (ignore ImageContent, AudioContent) Args: message: NAT Message object Returns: Dict with 'role' and 'content' (text string) suitable for message processing """ content = message.content if isinstance(content, str): text_content = content elif isinstance(content, list): # Extract text from TextContent items only (skip ImageContent, AudioContent) text_parts = [] for item in content: # TextContent has type="text" and a text attribute if getattr(item, "type", None) == "text" and hasattr(item, "text"): text_parts.append(item.text) text_content = "\n".join(text_parts) else: text_content = str(content) return {"role": message.role.value if hasattr(message.role, "value") else message.role, "content": text_content} # Helper function to extract text from message content (handles both string and list formats) def _get_content_text(msg: BaseMessage) -> str: content = msg.content if isinstance(content, str): return content # content is list[str | dict[str, Any]] # Extract text from list of dicts (e.g., [{'type': 'text', 'text': '...'}]) texts: list[str] = [] for item in content: if isinstance(item, dict) and "text" in item: texts.append(str(item["text"])) elif isinstance(item, str): texts.append(item) return " ".join(texts) def strip_frontend_tags(content: str) -> str: """ Strip frontend display tags from message content. Args: content: The message content that may contain frontend tags Returns: The content with frontend tags replaced by descriptive text """ if not content or not isinstance(content, str): return content or "" # Replace ... with placeholder cleaned = re.sub(r".*?", "[Incident data]", content, flags=re.DOTALL) return cleaned class TopAgentState(BaseModel): """State for the Top Agent conversation tracking""" current_message: BaseMessage | None = Field(default=None, description="Current user query") agent_scratchpad: list[BaseMessage] = Field(default_factory=list, description="Agent thoughts / intermediate steps") conversation_history: list[BaseMessage] = Field( default_factory=list, description="Recent conversation messages as HumanMessage/AIMessage (agent-think stripped)", ) iteration_count: int = Field(default=0, description="Current iteration count") final_answer: str = Field(default="", description="Final answer from the agent") plan: str = Field(default="", description="Execution plan drafted by the plan node") previous_conversation: str = Field(default="", description="Previous conversation summary") llm_reasoning: bool = Field(default=False, description="Enable LLM reasoning mode") vlm_reasoning: bool | None = Field( default=None, description="Enable VLM reasoning mode (If None, use tool default)" ) search_source_type: str = Field(default="video_file", description="Video source type for search agent") class TopAgentConfig(FunctionBaseConfig, name="top_agent"): """Config for the Top Agent.""" tool_names: list[FunctionRef] = Field( default_factory=list, description="The list of regular tools to provide to the top agent (e.g., get_fov_counts_with_chart).", ) subagent_names: list[str] = Field( default_factory=list, description="Names of sub-agents that support native streaming (e.g., ['report_agent', 'multi_report_agent']). " "These will be called with their native streaming interface to show internal reasoning steps.", ) llm_name: LLMRef = Field(description="The LLM model to use with the top agent.") log_level: str = Field(default="INFO", description="Logging level for the agent (DEBUG, INFO, WARNING, ERROR).") max_iterations: int = Field(default=10, description="Maximum number of iterations for the agent.") max_history: int = Field( default=10, ge=0, description="Maximum number of messages to keep in the conversation history. Set to 0 to disable.", ) prompt: str = Field(..., description="The prompt to use for the top agent.") llm_reasoning: bool = Field(default=False, description="Enable LLM reasoning mode.") planning_enabled: bool = Field(default=False, description="Enable plan-then-execute mode.") plan_prompt: str | None = Field( default=None, description="Prompt for the plan node. If None, a default planning instruction is used.", ) tool_call_prompt: str | None = Field( default=None, description="Tool call rules prompt. If None and planning is enabled, extracted from the main prompt via LLM.", ) response_format_prompt: str | None = Field( default=None, description="Response format rules prompt. If None and planning is enabled, extracted from the main prompt via LLM.", ) # Postprocessing configuration postprocessing: PostprocessingConfig | None = Field( default=None, description="Postprocessing configuration.", ) class TopAgent(AsyncMixin): """Top-level routing agent with native tool calling""" llm: BaseChatModel llm_with_tools: Runnable[Any, BaseMessage] subagent_functions: dict[str, Any] subagent_names: set[str] callbacks: list[BaseCallbackHandler] max_iterations: int prompt: ChatPromptTemplate plan_exec_prompt: ChatPromptTemplate | None tools_dict: dict[str, BaseTool] graph: CompiledStateGraph checkpointer: InMemorySaver planning_enabled: bool plan_prompt: str | None plan_system_prompt: str tool_call_prompt: str response_format_prompt: str @override async def __ainit__( self, llm: BaseChatModel, prompt: ChatPromptTemplate, tools: list[BaseTool] | None = None, subagents: list[BaseTool] | None = None, subagent_functions: dict[str, Any] | None = None, callbacks: list[BaseCallbackHandler] | None = None, max_iterations: int = 10, max_history: int = 3, postprocessing_config: PostprocessingConfig | None = None, postprocessing_llm: BaseChatModel | None = None, planning_enabled: bool = False, plan_prompt: str | None = None, plan_exec_prompt: ChatPromptTemplate | None = None, plan_system_prompt: str = "", tool_call_prompt: str = "", response_format_prompt: str = "", ) -> None: logger.info("Initializing Top Agent") await super().__ainit__() self.llm = llm self.max_history = max_history tools_list = tools or [] subagents_list = subagents or [] # Merge tools and subagents for LLM binding subagents_plus_tools = tools_list + subagents_list self.llm_with_tools = llm.bind_tools(subagents_plus_tools) if subagents_plus_tools else llm # Track which tools are subagents and store their native functions self.subagent_functions = subagent_functions or {} self.subagent_names = set(self.subagent_functions.keys()) self.callbacks = callbacks or [] self.max_iterations = max_iterations # Initialize postprocessing if config is present self.postprocessing = ( PostprocessingNode(postprocessing_config, llm=postprocessing_llm) if postprocessing_config else None ) logger.info( "Setting up top agent with %d regular tools, %d sub-agents", len(tools_list), len(subagents_list), ) if self.subagent_names: logger.info("Sub-agents with native streaming: %s", list(self.subagent_names)) # Store prompt for dynamic agent creation with model parameters self.prompt = prompt self.plan_exec_prompt = plan_exec_prompt self.planning_enabled = planning_enabled self.plan_prompt = plan_prompt self.plan_system_prompt = plan_system_prompt self.tool_call_prompt = tool_call_prompt self.response_format_prompt = response_format_prompt self.tools_dict = {tool.name: tool for tool in subagents_plus_tools} self.graph = await self._build_graph() logger.info("Successfully initialized Top Agent with %d total tools", len(self.tools_dict)) def _get_tool(self, tool_name: str) -> BaseTool | None: """Get a tool by name from the tools dict.""" tool = self.tools_dict.get(tool_name) if tool is None: logger.error("Tool not found: %s. Available tools: %s", tool_name, list(self.tools_dict.keys())) return tool async def _plan_update_node(self, state: TopAgentState) -> TopAgentState: """ Plan-update node: uses the LLM to dynamically update the execution plan based on tool results in the scratchpad, then clears the scratchpad. The LLM handles structural updates (marking [x], adjusting steps). Exact tool results are appended programmatically so nothing is lost. """ if not state.agent_scratchpad: return state writer = get_stream_writer() logger.debug("Starting Plan Update Node") # Extract tool calls and results from the scratchpad. # scratchpad_lines → concise summary for the LLM prompt # tool_results_lines → exact results appended programmatically scratchpad_lines: list[str] = [] tool_results_lines: list[str] = [] pending_calls: dict[str, dict[str, Any]] = {} # tool_call_id -> {name, args} for msg in state.agent_scratchpad: if isinstance(msg, AIMessage) and msg.tool_calls: for tc in msg.tool_calls: tc_id = tc["id"] or "" pending_calls[tc_id] = {"name": tc["name"], "args": tc["args"]} scratchpad_lines.append(f"Called tool `{tc['name']}` with args: {tc['args']}") elif isinstance(msg, ToolMessage): call_info = pending_calls.pop(msg.tool_call_id, None) tool_name = (call_info["name"] if call_info else None) or getattr(msg, "name", None) or "tool" result_text = _get_content_text(msg) # Full result for programmatic appendix tool_results_lines.append(f"`{tool_name}` result:\n{result_text}") # Truncated for the LLM prompt truncated = result_text[:500] + "…" if len(result_text) > 500 else result_text scratchpad_lines.append(f"Result from `{tool_name}`: {truncated}") else: text = _get_content_text(msg) if text.strip(): scratchpad_lines.append(text) scratchpad_summary = "\n".join(scratchpad_lines) # Strip previous tool results section before sending plan to LLM clean_plan = state.plan.split(_TOOL_RESULTS_DELIMITER)[0].rstrip() system_content = ( "You are a plan-tracking assistant. You will be given an execution plan and " "a scratchpad of recent tool calls and their results.\n\n" "Your job:\n" "- Mark completed steps with [x] and append a concise result summary.\n" "- Keep pending steps with [ ].\n" "- Adjust, add, or remove remaining steps based on what was learned from the results.\n" "- Return ONLY the updated plan — no commentary, no preamble.\n" ) thinking_tag = get_thinking_tag(self.llm, state.llm_reasoning) if thinking_tag: system_content += f"\n{thinking_tag}" messages: list[BaseMessage] = [ SystemMessage(content=system_content), HumanMessage( content=( f"Current plan:\n{clean_plan}\n\nScratchpad (recent tool calls and results):\n{scratchpad_summary}" ) ), ] llm_kwargs = get_llm_reasoning_bind_kwargs(self.llm, state.llm_reasoning) llm_to_use = self.llm.bind(**llm_kwargs) if llm_kwargs else self.llm result = await llm_to_use.ainvoke(messages, config=RunnableConfig(callbacks=self.callbacks)) _, updated_plan = parse_reasoning_content(result) if not updated_plan: updated_plan = str(result.content) if hasattr(result, "content") else clean_plan # Programmatically append exact tool results so the agent has them if tool_results_lines: updated_plan += _TOOL_RESULTS_DELIMITER + "\n\n".join(tool_results_lines) logger.info("Plan update node produced updated plan:\n%s", updated_plan) writer(AgentMessageChunk(type=AgentMessageChunkType.THOUGHT, content="Updated Plan:\n\n" + updated_plan)) state.plan = updated_plan state.agent_scratchpad = [] return state def _tool_accepts_param(self, tool_name: str, param_name: str) -> bool: """Check if a tool accepts a specific parameter by inspecting its schema.""" tool = self.tools_dict.get(tool_name) if tool and hasattr(tool, "args_schema") and tool.args_schema is not None: schema_fields = getattr(tool.args_schema, "model_fields", {}) return param_name in schema_fields return False async def astream( self, input_messages: list[BaseMessage], llm_reasoning: bool = False, vlm_reasoning: bool = False, search_source_type: str = "video_file", ) -> AsyncGenerator[AgentMessageChunk]: """Stream the agent's response.""" if not input_messages: raise RuntimeError(EMPTY_MESSAGES_ERROR) current_message = input_messages[-1] logger.info(f"Current message: {current_message.content[:50] if current_message.content else '(empty)'}...") # Get conversation_id from ContextVar thread_id = ContextState.get().conversation_id.get() previous_state = self.graph.get_state({"configurable": {"thread_id": thread_id}}).values if previous_state and self.max_history > 0: # Follow up question, add previous messages to the current messages logger.info("Follow a previous conversation %s: %s", thread_id, previous_state) # Retrieve conversation history from previous state conversation_history = previous_state.get("conversation_history", []) logger.info(f"Retrieved {len(conversation_history)} messages of conversation history from previous state") # Only summarize when history has reached max_history. # Summarize the older half into previous_conversation, keep the newer half. previous_conversation = previous_state.get("previous_conversation", "") half = self.max_history // 2 if len(conversation_history) >= self.max_history: older_half = conversation_history[:half] conversation_history = conversation_history[half:] logger.info( "History reached max_history (%d), summarizing older %d messages, keeping newer %d", self.max_history, len(older_half), len(conversation_history), ) older_text = "\n".join(_get_content_text(m) for m in older_half) summary_thinking_tag = get_thinking_tag(self.llm, llm_reasoning) summary_prompt = ( "Briefly summarize the conversation history in 2-3 sentences:\n" "- What did the user ask?\n" "- What tools were called?\n" "- What was the high-level outcome?\n\n" "Keep it concise.\n\n" "Older conversation summary: {older_conversation_summary}\n" "Latest messages:\n{latest_messages}" ) summary_messages: list[BaseMessage] = [] if summary_thinking_tag: summary_messages.append(SystemMessage(content=summary_thinking_tag)) summary_messages.append( HumanMessage( content=summary_prompt.format( older_conversation_summary=previous_conversation, latest_messages=older_text, ) ) ) llm_kwargs = get_llm_reasoning_bind_kwargs(self.llm, llm_reasoning) llm_to_use = self.llm.bind(**llm_kwargs) if llm_kwargs else self.llm summary_result = await llm_to_use.ainvoke( summary_messages, config=RunnableConfig(callbacks=self.callbacks) ) summary_reasoning, summary_content = parse_reasoning_content(summary_result) if summary_reasoning: previous_conversation = summary_content else: previous_conversation = summary_content or summary_result.content logger.info( "Summarized older history into previous_conversation (%d chars)", len(previous_conversation) ) input_state = TopAgentState( current_message=copy.deepcopy(current_message), previous_conversation=previous_conversation, conversation_history=list(conversation_history), agent_scratchpad=[], final_answer="", llm_reasoning=llm_reasoning, vlm_reasoning=vlm_reasoning, search_source_type=search_source_type, ) else: input_state = TopAgentState( current_message=copy.deepcopy(current_message), previous_conversation="", conversation_history=[], agent_scratchpad=[], llm_reasoning=llm_reasoning, vlm_reasoning=vlm_reasoning, search_source_type=search_source_type, ) try: config: RunnableConfig = RunnableConfig( configurable={ "thread_id": thread_id, "stream": True, }, recursion_limit=self.max_iterations, ) async for chunk in self.graph.astream(input=input_state, config=config, stream_mode="custom"): if isinstance(chunk, AgentMessageChunk): yield chunk except Exception as ex: logger.exception("Failed to stream agent") error_chunk = AgentMessageChunk( type=AgentMessageChunkType.ERROR, content=f"Error: {ex}", ) yield error_chunk user_message = "Sorry, I wasn't able to complete your request. Please try again. If the issue persists, please contact your administrator." yield AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=user_message) async def _plan_node(self, state: TopAgentState) -> TopAgentState: """ Planning node: drafts a step-by-step execution plan using tool names/descriptions only. Invokes the LLM without tool bindings so it focuses on planning rather than executing. The resulting plan is stored in state.plan and emitted as a THOUGHT chunk. """ writer = get_stream_writer() logger.debug("Starting Plan Node") if state.current_message is None: raise RuntimeError(EMPTY_MESSAGES_ERROR) question = state.current_message.content if not isinstance(question, str): question = str(question) # TODO: Hack for UI to show the uploaded video, use commands "/show" to by pass plan in next release. lowered_question = question.lower() if lowered_question.startswith("let's show the videos just uploaded"): logger.info("Plan node: by pass plan for showing uploaded video") state.plan = ( "1. Call vst_video_clip tool in parallel with each video name as a separate input:" + lowered_question.removeprefix("let's show the videos just uploaded").removesuffix("?") ) state.plan += ( "\n\n 2. Format the result url into html tags like " ) return state # Build one-line description per tool tool_descriptions = "\n".join(f"- {t.name}: {t.description}" for t in self.tools_dict.values()) tool_descriptions_block = f"\n\nAvailable tools:\n{tool_descriptions}" previous_exec_feedback = "" if state.agent_scratchpad: previous_exec_feedback = "\n\nPrevious execution feedback:\n" + "\n".join( _get_content_text(m) for m in state.agent_scratchpad ) planning_instruction = self.plan_prompt or ( "Review the available tools, the conversation history, and the user's question, " "then produce a concise numbered execution plan. Start each step with a tool name and a brief description of the step.\n" "Put relevant context (e.g. sensor IDs or time ranges) from the conversation history directly in the plan steps " "so the execution agent does not need to re-read the history.\n" "If the user's request is too ambiguous to build a reliable plan, respond with EXACTLY:\n" "[USER] \n" "If user's question can be answered directly without any tools, respond with EXACTLY:\n" "[USER] \n" "This will be sent back to the user directly — do NOT produce a plan in that case.\n\n" "Example plan:\n" "1. Call `get_sensor_ids` — resolve the camera the user mentioned (camera 3 from prior turn).\n" "2. Call `get_event_clips` with sensor_id from step 1 and time range 08:00-09:00 from the query.\n" "3. Summarize the clips and return them to the user.\n\n" "Example clarify:\n" "[USER] Which video or camera are you referring to? " "Please provide a sensor name or video ID so I can look it up." "Example direct answer:\n" "what tools are available?\n" "[USER] The available tools are: ... (list of tools)" ) # Include previous conversation summary in the system message so the plan can reference prior context logger.debug("Planning instruction: " + planning_instruction) logger.debug("Tool descriptions: " + tool_descriptions_block) summary_block = "" if state.previous_conversation: summary_block = f"\n\nPrevious conversation summary:\n{state.previous_conversation}\n\n" logger.debug("Summary: " + summary_block) system_content = ( self.plan_system_prompt + planning_instruction + tool_descriptions_block + summary_block + previous_exec_feedback ) thinking_tag = get_thinking_tag(self.llm, state.llm_reasoning) if thinking_tag: system_content += f"\n{thinking_tag}" # Include recent conversation history so the plan can reference prior turns messages: list[BaseMessage] = [SystemMessage(content=system_content)] if state.conversation_history and self.max_history > 0: messages.extend(state.conversation_history) messages.append(HumanMessage(content="User question: " + question)) llm_kwargs = get_llm_reasoning_bind_kwargs(self.llm, state.llm_reasoning) llm_to_use = self.llm.bind(**llm_kwargs) if llm_kwargs else self.llm result = await llm_to_use.ainvoke(messages, config=RunnableConfig(callbacks=self.callbacks)) plan_reasoning, plan_text = parse_reasoning_content(result) if not plan_text: plan_text = str(result.content) if hasattr(result, "content") else "" logger.debug("Plan node produced plan:\n%s", plan_text) if plan_reasoning: logger.debug("Plan node reasoning:\n%s", plan_reasoning) # Check if the planner wants to ask the user for clarification if plan_text.strip().startswith(PLAN_CLARIFY_PREFIX): clarification = plan_text.strip()[len(PLAN_CLARIFY_PREFIX) :].strip() logger.info("Plan node requesting clarification: %s", clarification) state.final_answer = clarification return state writer(AgentMessageChunk(type=AgentMessageChunkType.THOUGHT, content="Plan: \n\n" + plan_text)) state.plan = plan_text logger.info(f"Plan node produced plan: {plan_text}") return state async def agent_node(self, state: TopAgentState) -> TopAgentState: """ Main reasoning node for the top agent. This node calls the LLM to decide what action to take next. Returns the updated state with the agent's response. """ writer = get_stream_writer() logger.debug("Starting Agent Node") if state.current_message is None: raise RuntimeError(EMPTY_MESSAGES_ERROR) if ( len(state.agent_scratchpad) == 0 and isinstance(state.current_message.content, str) and state.current_message.content.strip() == "" ): logger.error("No human input passed to the agent.") writer(AgentMessageChunk(type=AgentMessageChunkType.FINAL, content=NO_INPUT_ERROR_MESSAGE)) state.final_answer = NO_INPUT_ERROR_MESSAGE return state question = state.current_message.content try: # Get the thinking tag based on the LLM model and llm_reasoning state thinking_tag = get_thinking_tag(self.llm, state.llm_reasoning) thinking_tag_formatted = f"\n{thinking_tag}" if thinking_tag else "" if thinking_tag: logger.info(f"Applying thinking tag: '{thinking_tag}'") llm_kwargs = get_llm_reasoning_bind_kwargs(self.llm, state.llm_reasoning) llm_to_use = self.llm_with_tools.bind(**llm_kwargs) if llm_kwargs else self.llm_with_tools if state.plan and self.plan_exec_prompt is not None: prompt_to_use = self.plan_exec_prompt logger.info("Using plan (updated by plan_update node):\n%s", state.plan) invoke_kwargs: dict[str, Any] = { "question": question, "plan_section": state.plan, # Already updated by plan_update node "current_time": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"), "thinking_tag": thinking_tag_formatted, } else: prompt_to_use = self.prompt invoke_kwargs = { "question": question, "conversation_summary": state.previous_conversation, "agent_scratchpad": state.agent_scratchpad, "conversation_history": state.conversation_history, "current_time": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"), "thinking_tag": thinking_tag_formatted, } agent_to_use = prompt_to_use | llm_to_use output_message = await agent_to_use.ainvoke( invoke_kwargs, config=RunnableConfig(callbacks=self.callbacks), ) reasoning, final_result = parse_reasoning_content(output_message) logger.debug("The user's question was: %s", question) logger.debug("The agent's thoughts are:\n%s", reasoning) logger.debug("The agent's final result is:\n%s", final_result) if reasoning: writer(AgentMessageChunk(type=AgentMessageChunkType.THOUGHT, content=reasoning)) # Get tool_calls if output_message is AIMessage tool_calls: list[Any] = [] if isinstance(output_message, AIMessage) and output_message.tool_calls: tool_calls = output_message.tool_calls # Check if we have a final answer if final_result and not tool_calls: state.final_answer = final_result logger.debug("Agent provided final answer (pending postprocessing validation)") # Still add the final answer to the scratchpad for the conversation history summary and postprocessing retries # Add agent response to scratchpad # Combine reasoning and content to preserve full context # Format the summary response without think tags to avoid confusion with the think tag in the system message if reasoning: full_content = f"The model's reasoning is: {reasoning}\nThe model's answer is: {final_result or ''}" else: full_content = final_result or "" # Local Nemotron Nano 9b v2 NIM requires content to be non-empty # Use a single space as a minimal valid placeholder instead of empty string model_name = getattr(self.llm, "model_name", "") or getattr(self.llm, "model", "") model_name = str(model_name).lower() if model_name else "" if full_content.strip() == "": logging.info("Full content is empty, setting to 'Agent wants to call tools.") full_content = "Agent wants to call tools." if tool_calls: state.agent_scratchpad.append(AIMessage(content=full_content, tool_calls=tool_calls)) else: state.agent_scratchpad.append(AIMessage(content=full_content)) return state except Exception as e: logger.exception("Failed to call agent_node") raise e async def tool_or_subagent_node(self, state: TopAgentState) -> TopAgentState: """ Execute tools or sub-agents requested by the agent. This node can handle both: - Regular tools (like video_analytics_mcp.video_analytics.get_sensor_ids) - Sub-agents (like report_agent) that may return structured output with thinking traces """ writer = get_stream_writer() try: logger.debug("Starting tool/sub-agent execution") if not state.agent_scratchpad or len(state.agent_scratchpad) == 0: raise RuntimeError(EMPTY_SCRATCHPAD_ERROR) last_message = state.agent_scratchpad[-1] if not isinstance(last_message, AIMessage): raise RuntimeError("Expected AIMessage in agent_scratchpad for tool execution") agent_output: AIMessage = last_message tool_calls: list[Any] = agent_output.tool_calls if hasattr(agent_output, "tool_calls") else [] if not tool_calls: logger.warning("No tool calls found in agent output") return state requested_tool_names = [tool_call["name"] for tool_call in tool_calls] requested_tools = [self._get_tool(tool_name) for tool_name in requested_tool_names] if not requested_tools: configured_tool_names = list(self.tools_dict.keys()) logger.warning( "Some requested tools not found: %s. Available: %s", requested_tool_names, configured_tool_names, ) error_message = HumanMessage( content=TOOL_NOT_FOUND_ERROR_MESSAGE.format( tool_name=requested_tool_names, tools=configured_tool_names, ), ) state.agent_scratchpad.append(error_message) return state # Run the tool/sub-agent async def run_tool(tool: BaseTool | None, tool_call: dict[str, Any]) -> ToolMessage: try: if tool is None: return ToolMessage( name=tool_call["name"], tool_call_id=tool_call["id"], content=f"Tool '{tool_call['name']}' not found", ) logger.info(f"Executing tool/sub-agent: {tool_call['name']}") tool_response: Any = None # Check if this is a sub-agent that we should call natively for streaming tool_name = tool_call["name"] is_subagent = tool_name in self.subagent_names # Build tool args once, filtering None values and injecting llm_reasoning/vlm_reasoning if supported tool_args = {k: v for k, v in tool_call["args"].items() if v is not None} if self._tool_accepts_param(tool_name, "llm_reasoning"): tool_args["llm_reasoning"] = state.llm_reasoning logger.info(f"Passing llm_reasoning={state.llm_reasoning} to {tool_name}") if self._tool_accepts_param(tool_name, "vlm_reasoning"): tool_args["vlm_reasoning"] = state.vlm_reasoning logger.info(f"Passing vlm_reasoning={state.vlm_reasoning} to {tool_name}") # Only inject search_source_type for search_agent (video_file/rtsp). report_agent and # others use source_type with different semantics (e.g. sensor/place) if tool_name == "search_agent" and self._tool_accepts_param(tool_name, "source_type"): tool_args["source_type"] = state.search_source_type logger.info(f"Passing source_type={state.search_source_type} to {tool_name}") # Use native streaming for configured sub-agents final_chunks = [] if is_subagent: # Yield sub-agent call message before processing subagent_msg = f"Calling sub-agent: {tool_name}\nArgs: {tool_call['args']}" writer(AgentMessageChunk(type=AgentMessageChunkType.SUBAGENT_CALL, content=subagent_msg)) # Emit TOOL_START telemetry for subagent subagent_run_id = str(uuid4()) subagent_start_time = time.time() saved_context = None try: step_manager = Context.get().intermediate_step_manager # Save the current context state before emitting TOOL_START context_state = step_manager._context_state saved_context = context_state.active_span_id_stack.get().copy() tool_start_payload = IntermediateStepPayload( event_type=IntermediateStepType.TOOL_START, framework=LLMFrameworkEnum.LANGCHAIN, name=tool_name, UUID=subagent_run_id, data=StreamEventData(input=json.dumps(tool_call["args"])), metadata=TraceMetadata(tool_inputs=tool_call["args"]), usage_info=UsageInfo(token_usage=TokenUsageBaseModel()), ) step_manager.push_intermediate_step(tool_start_payload) logger.info(f"TOOL_START telemetry emitted for {tool_name} with UUID {subagent_run_id}") except Exception as e: logger.warning(f"Failed to emit TOOL_START telemetry for {tool_name}: {e}", exc_info=True) nat_function = self.subagent_functions[tool_name] async for chunk in nat_function.astream(tool_args): if isinstance(chunk, AgentMessageChunk): logger.debug(f"Received AgentMessageChunk from {tool_name}: type={chunk.type}") if chunk.type == AgentMessageChunkType.FINAL: # Try to parse as AgentOutput JSON for sub-agents try: agent_output = AgentOutput.model_validate_json(chunk.content) logger.debug(f"Received AgentOutput from {tool_name} via FINAL chunk") final_content_parts = [] if agent_output.messages: final_content_parts.extend(agent_output.messages) if agent_output.side_effects: for value in agent_output.side_effects.values(): final_content_parts.append(f"{value}") final_content = "\n".join(final_content_parts) final_chunks.append(final_content) state.final_answer = final_content logger.info( f"Set state.final_answer from {tool_name} (pending postprocessing validation)" ) if agent_output.messages: tool_response = f"tool: {tool_name} completed. Result: {' '.join(agent_output.messages)}" else: tool_response = f"tool: {tool_name} completed. Result: {final_content}" except (json.JSONDecodeError, Exception): # Not AgentOutput JSON, treat as plain text final_chunks.append(chunk.content) state.final_answer = chunk.content logger.info( f"Set state.final_answer from {tool_name} (pending postprocessing validation)" ) tool_response = chunk.content else: # For non-FINAL chunks, yield directly writer(chunk) else: # Store non-AgentMessageChunk results final_chunks.append(str(chunk)) tool_response = chunk # Emit TOOL_END telemetry for subagent try: step_manager = Context.get().intermediate_step_manager subagent_output = ( tool_response or "\n".join(final_chunks) or f"Subagent {tool_name} completed" ) logger.info( f"Emitting TOOL_END for {tool_name} with UUID {subagent_run_id}, output length: {len(str(subagent_output))}" ) # Manually ensure the step is in outstanding_start_steps # This is needed because the async subagent execution may have lost the context if ( subagent_run_id not in step_manager._outstanding_start_steps and saved_context is not None ): from nat.builder.intermediate_step_manager import OpenStep logger.info(f"Manually registering outstanding step for {tool_name}") parent_step_id = saved_context[-1] if saved_context else None step_manager._outstanding_start_steps[subagent_run_id] = OpenStep( step_id=subagent_run_id, step_name=tool_name, step_type=IntermediateStepType.TOOL_START, step_parent_id=parent_step_id, prev_stack=saved_context, active_stack=[*saved_context, subagent_run_id], ) tool_end_payload = IntermediateStepPayload( event_type=IntermediateStepType.TOOL_END, span_event_timestamp=subagent_start_time, framework=LLMFrameworkEnum.LANGCHAIN, name=tool_name, UUID=subagent_run_id, metadata=TraceMetadata(tool_outputs=subagent_output), usage_info=UsageInfo(token_usage=TokenUsageBaseModel()), data=StreamEventData(input=json.dumps(tool_call["args"]), output=subagent_output), ) step_manager.push_intermediate_step(tool_end_payload) logger.info(f"TOOL_END telemetry emitted for {tool_name}") except Exception as e: logger.warning(f"Failed to emit TOOL_END telemetry for {tool_name}: {e}", exc_info=True) else: # Use LangChain streaming for regular tools async for chunk in tool.astream( input=tool_args, config=RunnableConfig(callbacks=self.callbacks), ): if isinstance(chunk, AgentMessageChunk): logger.debug(f"Received AgentMessageChunk from {tool_call['name']}: type={chunk.type}") # Yield the chunk directly to the stream writer writer(chunk) if chunk.type == AgentMessageChunkType.FINAL: final_chunks.append(chunk.content) # Mark that we have a final answer state.final_answer = chunk.content logger.info(f"Set state.final_answer from {tool_call['name']}") else: tool_response = chunk # If no response was captured, use a default summary if tool_response is None: tool_response = f"tool: {tool_call['name']} completed" # Convert tool response to string for scratchpad and check for summary field tool_response_str = str(tool_response) if ( not is_subagent and not state.final_answer and hasattr(tool_response, "summary") and tool_response.summary ): # Extract summary but defer FINAL chunk until postprocessing validates it final_content = tool_response.summary state.final_answer = final_content logger.info(f"Extracted summary from {tool_call['name']} (pending postprocessing validation)") # Use a shorter message for scratchpad and reasoning trace tool_response_str = f"Returned summary with {len(final_content)} characters" # Yield tool call in reasoning trace for regular tools (even if we extracted a summary) # Sub-agents already yielded their call message earlier if not is_subagent: # For regular tools, yield TOOL_CALL with call info and result result_msg = ( f"Tool: {tool_call['name']}\nArgs: {tool_call['args']}\nResult: {tool_response_str}" ) writer(AgentMessageChunk(type=AgentMessageChunkType.TOOL_CALL, content=result_msg)) logger.debug( f"Tool {tool_call['name']} completed, final_answer={'set' if state.final_answer else 'not set'}" ) # Convert empty tool response to placeholder tool_content = tool_response if not tool_content or (isinstance(tool_content, str) and tool_content.strip() == ""): logger.warning(f"Tool {tool_call['name']} returned empty content, using placeholder") tool_content = "Tool returned empty content" return ToolMessage( name=tool_call["name"], tool_call_id=tool_call["id"], content=tool_content, ) except Exception as ex: logger.exception("Tool execution failed") error_response = f"Tool call failed: {ex!s}" return ToolMessage( name=tool_call["name"], tool_call_id=tool_call["id"], content=error_response, ) # Execute all tool calls tasks = [run_tool(tool, tool_call) for tool, tool_call in zip(requested_tools, tool_calls, strict=False)] for task in asyncio.as_completed(tasks): tool_response = await task state.agent_scratchpad.append(tool_response) # Add final answer to scratchpad for conversation history summary and postprocessing retries if state.final_answer: state.agent_scratchpad.append(AIMessage(content=state.final_answer)) except Exception as ex: logger.exception("Failed to call tool_or_subagent_node") state.agent_scratchpad.append(HumanMessage(content=str(ex))) return state async def _postprocessing_node(self, state: TopAgentState) -> TopAgentState: """Postprocess output: validate before finalizing the graph.""" if not self.postprocessing or not self.postprocessing.config.enabled or not state.final_answer: return state user_query = "" if state.current_message and hasattr(state.current_message, "content"): user_query = str(state.current_message.content) if state.current_message.content else "" result = await self.postprocessing.process( state.final_answer, user_query=user_query, scratchpad=state.agent_scratchpad, llm_reasoning=state.llm_reasoning, ) if result.passed: logger.info("Postprocessing passed") else: logger.info(f"Postprocessing failed: {result.feedback}") state.final_answer = "" feedback_message = f"{POSTPROCESSING_FEEDBACK_MARKER}\n{result.feedback}\nPlease try again." state.agent_scratchpad.append(HumanMessage(content=feedback_message)) logger.info("Appended postprocessing feedback to scratchpad") return state async def _conditional_edge(self, state: TopAgentState) -> str: """Determine next action from agent node.""" try: logger.debug("Starting Conditional Edge") # Check if we have a final answer if state.final_answer: logger.info("Agent has final answer, ending: %s", state.final_answer) return AgentDecision.END.value # Check last message in scratchpad if not state.agent_scratchpad: logger.debug("No scratchpad, routing to agent") return AgentDecision.AGENT.value agent_output = state.agent_scratchpad[-1] if isinstance(agent_output, AIMessage): if agent_output.tool_calls: logger.info("Agent is calling %d tools", len(agent_output.tool_calls)) return AgentDecision.TOOL.value else: logger.info("Agent has no tool calls, ending") return AgentDecision.END.value else: # Tool message or human message, route back to agent logger.debug("Last message is not AIMessage, routing to agent") return AgentDecision.AGENT.value except Exception: logger.exception("Failed to determine next action") logger.warning("Ending graph traversal due to error") return AgentDecision.END.value async def _conditional_edge_from_tool(self, state: TopAgentState) -> str: """Conditional edge from tool node - check if we should end or continue to agent.""" try: if state.final_answer: logger.info("Tool node set final_answer, ending graph traversal") return AgentDecision.END.value else: logger.debug("Tool finished, continuing to agent") return AgentDecision.AGENT.value except Exception: logger.exception("Failed to determine next step from tool") return AgentDecision.AGENT.value async def finalize_node(self, state: TopAgentState) -> TopAgentState: """Final node that emits FINAL chunk and updates conversation history.""" if state.final_answer: # Remove backslash-escaped quotes (LLM artifact from JSON context, e.g. src=\"url\" -> src="url") state.final_answer = state.final_answer.replace('\\"', '"').replace("\\'", "'") # strip inline code quotes e.g. `abc` -> abc, but leave code blocks unchanged state.final_answer = re.sub(r"(? 0: # Append new turn, keep last max_history messages state.conversation_history.append(HumanMessage(content=state.current_message.content)) state.conversation_history.append(AIMessage(content=cleaned_response)) logger.info( f"Updated conversation history in finalize_node: {len(state.conversation_history)} messages (max {self.max_history})" ) return state async def _build_graph(self) -> CompiledStateGraph: try: self.checkpointer = InMemorySaver() graph = StateGraph(TopAgentState) graph.add_node("agent", self.agent_node) graph.add_node("tool", self.tool_or_subagent_node) graph.add_node("finalize", self.finalize_node) if self.postprocessing: # Validate before ending the graph graph.add_node("postprocessing", self._postprocessing_node) end_target = "postprocessing" else: end_target = "finalize" if self.planning_enabled: graph.add_node("plan", self._plan_node) graph.add_node("plan_update", self._plan_update_node) graph.set_entry_point("plan") # If the plan node set final_answer (clarification), skip to finalize; # otherwise proceed to agent for execution. graph.add_conditional_edges( "plan", lambda s: AgentDecision.END.value if s.final_answer else AgentDecision.AGENT.value, { AgentDecision.END.value: end_target, AgentDecision.AGENT.value: "agent", }, ) # tool → plan_update (if no final_answer) or end_target (if final_answer set) conditional_edge_from_tool_outputs: dict[Hashable, str] = { AgentDecision.END.value: end_target, AgentDecision.AGENT.value: "plan_update", } graph.add_conditional_edges( "tool", self._conditional_edge_from_tool, conditional_edge_from_tool_outputs ) graph.add_edge("plan_update", "agent") else: graph.set_entry_point("agent") # Make tool -> agent edge conditional to support tools that set final_answer tool_edge_outputs: dict[Hashable, str] = { AgentDecision.END.value: end_target, AgentDecision.AGENT.value: "agent", } graph.add_conditional_edges("tool", self._conditional_edge_from_tool, tool_edge_outputs) conditional_edge_possible_outputs: dict[Hashable, str] = { AgentDecision.TOOL.value: "tool", AgentDecision.END.value: end_target, AgentDecision.AGENT.value: "agent", } graph.add_conditional_edges("agent", self._conditional_edge, conditional_edge_possible_outputs) if self.postprocessing: if self.planning_enabled: graph.add_conditional_edges("postprocessing", lambda s: "finalize" if s.final_answer else "plan") else: graph.add_conditional_edges("postprocessing", lambda s: "finalize" if s.final_answer else "agent") graph.add_edge("finalize", "__end__") self.graph = graph.compile(checkpointer=self.checkpointer) logger.info("Agent Graph built and compiled successfully") return self.graph except Exception: logger.exception("Failed to build the Agent Graph") raise async def _extract_prompt_sections( llm: BaseChatModel, prompt_text: str, callbacks: list[BaseCallbackHandler] | None = None, ) -> tuple[str, str]: """Extract tool_call_prompt and response_format_prompt from the main prompt via LLM. Called at factory init time when planning is enabled but the user hasn't provided these prompts explicitly. The extracted prompts are used in the plan_exec_prompt so the execution agent knows how to call tools and format responses without re-reading the full system prompt. Returns: Tuple of (tool_call_prompt, response_format_prompt). Empty strings on failure. """ extraction_system = ( "You are a prompt analysis assistant. Given a system prompt, extract two specific sections:\n" "1. Tool Call Rules — any instructions about how to call tools, retry behavior, " "parameter requirements, error handling for tool calls.\n" "2. Response Format Rules — any instructions about response formatting, markdown, " "URL handling, output structure, phrases to avoid.\n\n" "Return ONLY the extracted text in the exact XML format below.\n" "If a section is not found in the prompt, leave the tags empty.\n\n" "\n\n" "\n" ) messages: list[BaseMessage] = [ SystemMessage(content=extraction_system), HumanMessage(content=f"Extract sections from this system prompt:\n\n{prompt_text}"), ] try: result = await llm.ainvoke(messages, config=RunnableConfig(callbacks=callbacks or [])) content = result.content if isinstance(result.content, str) else str(result.content) tool_call_match = re.search(r"(.*?)", content, re.DOTALL) response_format_match = re.search(r"(.*?)", content, re.DOTALL) tool_call = tool_call_match.group(1).strip() if tool_call_match else "" response_format = response_format_match.group(1).strip() if response_format_match else "" logger.info( "Extracted prompt sections: tool_call=%d chars, response_format=%d chars", len(tool_call), len(response_format), ) return tool_call, response_format except Exception: logger.exception("Failed to extract prompt sections via LLM, using empty defaults") return "", "" async def _get_subagents(subagent_names: list[str], builder: Builder) -> tuple[list[BaseTool], dict[str, Any]]: """ Setup sub-agents by fetching them as both LangChain tools and native NAT functions. Args: subagent_names: List of sub-agent names to setup builder: Builder instance for fetching tools and functions Returns: Tuple of (subagent_tools, subagent_functions) where: - subagent_tools: List of BaseTool for LLM binding - subagent_functions: Dict mapping subagent names to native NAT functions for streaming """ subagent_functions: dict[str, Any] = {} subagent_tools: list[BaseTool] = [] if not subagent_names: return subagent_tools, subagent_functions logger.info(f"Setting up sub-agents: {subagent_names}") for subagent_name in subagent_names: try: # Get as LangChain tool for the LLM subagent_tool = await builder.get_tool(subagent_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) subagent_tools.append(subagent_tool) # Get as native NAT function for streaming nat_function = await builder.get_function(subagent_name) if nat_function and hasattr(nat_function, "astream"): subagent_functions[subagent_name] = nat_function logger.info(f"Registered {subagent_name} for native streaming") else: logger.warning(f"{subagent_name} does not support streaming (no astream method)") except Exception as e: logger.error(f"Failed to setup sub-agent {subagent_name}: {e}") return subagent_tools, subagent_functions @register_function(config_type=TopAgentConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def top_agent(config: TopAgentConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """Top-level routing agent with simple tool calling""" # Configure agent logger level vss_logger = logging.getLogger("vss_agents") log_level = getattr(logging, config.log_level.upper(), logging.INFO) vss_logger.setLevel(log_level) # Configure handler if not already present if not vss_logger.handlers: new_handler = logging.StreamHandler() new_handler.setLevel(log_level) new_handler.setFormatter( logging.Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s") ) vss_logger.addHandler(new_handler) vss_logger.propagate = False else: for existing_handler in vss_logger.handlers: existing_handler.setLevel(log_level) logger.info(f"Logging configured at {config.log_level} level for all vss_agents modules") llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) # --- Resolve tool_call_prompt and response_format_prompt ----------------- tool_call_prompt = config.tool_call_prompt or "" response_format_prompt = config.response_format_prompt or "" prompts_explicitly_provided = bool(config.tool_call_prompt and config.response_format_prompt) # If planning is enabled and prompts weren't provided, extract them from the # main prompt via LLM so we can inject them into plan_exec_prompt. if config.planning_enabled and not prompts_explicitly_provided: tool_call_prompt, response_format_prompt = await _extract_prompt_sections(llm, config.prompt) # --- Build the main agent system prompt ---------------------------------- # When the user provided tool_call / response_format separately they have # removed those sections from config.prompt, so we need to append them. # When extracted, the main prompt already contains them — appending again is # harmless (slight repetition) but keeps both paths identical. agent_prompt_text = config.prompt if not config.planning_enabled: if config.tool_call_prompt: agent_prompt_text += "\n\n" + config.tool_call_prompt if config.response_format_prompt: agent_prompt_text += "\n\n" + config.response_format_prompt prompt = ChatPromptTemplate( [ ( "system", agent_prompt_text + "\n\n" + "current time: {current_time}" + "\n\nPrevious conversation summary: {conversation_summary}" + "{thinking_tag}", ), MessagesPlaceholder(variable_name="conversation_history", optional=True), ("user", "{question}"), MessagesPlaceholder(variable_name="agent_scratchpad", optional=True), ] ) # --- Build plan_exec_prompt (only when planning is enabled) -------------- plan_exec_prompt: ChatPromptTemplate | None = None if config.planning_enabled: plan_exec_system = ( "Follow the execution plan precisely to answer the user's question." "All necessary context (sensor IDs, time ranges, etc.) should be already encoded in the plan.\n\n" "If the plan lacks a required context or input parameter, ask the user for the missing information.\n\n" "[x] means a step has been completed and the result is appended.\n\n" "Summarize and return the final answer to the user after all steps are completed." ) if tool_call_prompt: plan_exec_system += "\n\n## Tool call rules:\n " + tool_call_prompt if response_format_prompt: plan_exec_system += "\n\n## Response format rules:\n " + response_format_prompt plan_exec_system += "\n\ncurrent time: {current_time}{thinking_tag}" plan_exec_prompt = ChatPromptTemplate( [ ("system", plan_exec_system), ("user", "User Question: {question}\n\nExecution Plan:\n{plan_section}"), ] ) # Get regular tools tools = await builder.get_tools(tool_names=config.tool_names, wrapper_type=LLMFrameworkEnum.LANGCHAIN) # Get sub-agents both as LangChain tools (for LLM) and as native NAT functions (for streaming) subagent_tools, subagent_functions = await _get_subagents(config.subagent_names, builder) logger.info(f"Total tools: {len(tools)} regular, {len(subagent_tools)} sub-agents") # Use custom LLM for postprocessing if specified, otherwise use workflow LLM postprocessing_llm = llm if config.postprocessing and config.postprocessing.validators: llm_rule_validator_cfg = config.postprocessing.validators.llm_based_rule_validator if llm_rule_validator_cfg and llm_rule_validator_cfg.llm_name: logger.info(f"Using custom LLM for postprocessing: {llm_rule_validator_cfg.llm_name}") postprocessing_llm = await builder.get_llm( llm_rule_validator_cfg.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN ) agent = cast( "TopAgent", await TopAgent( llm=llm, tools=tools, subagents=subagent_tools, subagent_functions=subagent_functions, max_iterations=config.max_iterations, max_history=config.max_history, prompt=prompt, postprocessing_config=config.postprocessing, postprocessing_llm=postprocessing_llm, planning_enabled=config.planning_enabled, plan_prompt=config.plan_prompt, plan_exec_prompt=plan_exec_prompt, plan_system_prompt=config.prompt, tool_call_prompt=tool_call_prompt, response_format_prompt=response_format_prompt, ), ) async def _response_fn( request: ChatRequestOrMessage, ) -> AsyncGenerator[str]: """Streaming top agent response. Args: request: ChatRequestOrMessage with messages and optional reasoning parameters """ # Validate as TopAgentRequest for typed access to llm_reasoning/vlm_reasoning fields typed_request = TopAgentRequest.model_validate(request.model_dump()) llm_reasoning = typed_request.llm_reasoning if typed_request.llm_reasoning is not None else config.llm_reasoning vlm_reasoning = typed_request.vlm_reasoning if typed_request.vlm_reasoning is not None else False search_source_type = typed_request.search_source_type if typed_request.search_source_type else "video_file" # Override with WebSocket payload values if present (WebSocket requests don't pass params through request object) context = Context.get() if hasattr(context.metadata, "payload") and isinstance(context.metadata.payload, dict): payload = context.metadata.payload llm_reasoning = bool(payload["llm_reasoning"]) if "llm_reasoning" in payload else llm_reasoning vlm_reasoning = bool(payload["vlm_reasoning"]) if "vlm_reasoning" in payload else vlm_reasoning search_source_type = ( str(payload["search_source_type"]) if "search_source_type" in payload else search_source_type ) logger.info( f"Extracted from WebSocket payload - llm_reasoning={llm_reasoning}, vlm_reasoning={vlm_reasoning}, search_source_type={search_source_type}" ) logger.info( "Creating Top Agent with llm_reasoning=%s, vlm_reasoning=%s, search_source_type=%s", llm_reasoning, vlm_reasoning, search_source_type, ) try: # Convert request to ChatRequest following NAT's agent pattern: # https://github.com/NVIDIA/NeMo-Agent-Toolkit/blob/6184d2fb/src/nat/agent/tool_calling_agent/register.py#L86-L99 chat_request = GlobalTypeConverter.get().convert(request, to_type=ChatRequest) # Extract only the latest message. Conversation history is managed by agent state current_message = HumanMessage(content=_extract_text_content(chat_request.messages[-1]).get("content", "")) # Collect all steps for unified trace steps = [] final_content = [] step_num = 0 # Stream agent responses async for chunk in agent.astream( input_messages=[current_message], llm_reasoning=llm_reasoning, vlm_reasoning=vlm_reasoning, search_source_type=search_source_type, ): if chunk.type == AgentMessageChunkType.THOUGHT: step_num += 1 # Replace \n with spaces to clean up the display clean_content = chunk.content.replace("\\n", " ").replace("\n", " ") steps.append(f'{clean_content}') elif chunk.type == AgentMessageChunkType.TOOL_CALL: step_num += 1 clean_content = chunk.content.replace("\\n", " ").replace("\n", " ") steps.append(f'{clean_content}') elif chunk.type == AgentMessageChunkType.SUBAGENT_CALL: step_num += 1 clean_content = chunk.content.replace("\\n", " ").replace("\n", " ") steps.append( f'{clean_content}' ) elif chunk.type == AgentMessageChunkType.FINAL: final_content.append(chunk.content) elif chunk.type == AgentMessageChunkType.ERROR: step_num += 1 clean_content = chunk.content.replace("\\n", " ").replace("\n", " ") steps.append(f'{clean_content}') # Yield all steps wrapped in unified agent-think if steps: steps_content = "\n".join(steps) agent_think_block = f"\n\n{steps_content}\n\n" logger.debug(f"Agent think block: {agent_think_block}") yield agent_think_block # Yield final content if final_content: final_output = "\n\n".join(final_content) + "\n\n" logger.debug(f"Final output: {final_output}") yield final_output except Exception as ex: logger.exception("Agent failed with exception") yield f"I seem to be having a problem. {ex}" async def _single_fn(request: ChatRequestOrMessage) -> str: message = "" async for chunk in _response_fn(request): message += chunk return message yield FunctionInfo.create(stream_fn=_response_fn, single_fn=_single_fn, input_schema=ChatRequestOrMessage) ================================================ FILE: agent/src/vss_agents/api/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/api/custom_fastapi_worker.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Custom FastAPI front-end worker that extends NAT's default worker to support additional streaming endpoints and a lightweight health check. """ import logging from fastapi import FastAPI from nat.builder.workflow_builder import WorkflowBuilder from nat.data_models.config import Config from nat.front_ends.fastapi.fastapi_front_end_plugin_worker import FastApiFrontEndPluginWorker logger = logging.getLogger(__name__) class CustomFastApiFrontEndWorker(FastApiFrontEndPluginWorker): """ Custom FastAPI front-end worker that extends NAT's default worker. """ def __init__(self, config: Config): super().__init__(config) logger.info("Initialized CustomFastApiFrontEndWorker") async def add_routes(self, app: FastAPI, builder: WorkflowBuilder) -> None: """ Override add_routes to add custom endpoints. Args: app: FastAPI application instance builder: WorkflowBuilder instance """ # Add standard NAT routes await super().add_routes(app, builder) # Remove NAT's default health endpoint and add our custom one # We need to override it to return the expected format for integration tests app.routes[:] = [route for route in app.routes if getattr(route, "path", None) != "/health"] # Add lightweight health endpoint (no telemetry) @app.get("/health", include_in_schema=False) async def health_check() -> dict: return {"value": {"isAlive": True}} logger.info("Registered custom /health endpoint (replaced NAT default)") # Add custom streaming routes if configured self._maybe_register_streaming_routes(app) def _maybe_register_streaming_routes(self, app: FastAPI) -> None: """Register streaming ingest routes (video upload and RTSP streams) only when configured.""" front_end_cfg = getattr(getattr(self.config, "general", None), "front_end", None) streaming_config = getattr(front_end_cfg, "streaming_ingest", None) if front_end_cfg else None # Register video upload streaming routes try: from vss_agents.api.video_search_ingest import register_streaming_routes logger.info("Adding video upload streaming routes...") register_streaming_routes(app, self.config) logger.info("Successfully registered video upload streaming routes") except ImportError as exc: logger.debug("Video streaming routes module not available: %s", exc) except ValueError as exc: if streaming_config is not None: logger.error("Streaming ingest configured but invalid: %s", exc) raise logger.info("Skipping video streaming routes (not configured): %s", exc) except Exception as exc: logger.error("Failed to register video streaming routes: %s", exc, exc_info=True) raise # Register RTSP stream management routes try: from vss_agents.api.rtsp_stream_api import register_rtsp_stream_api_routes logger.info("Adding RTSP stream management routes...") register_rtsp_stream_api_routes(app, self.config) logger.info("Successfully registered RTSP stream management routes") except ImportError as exc: logger.debug("RTSP stream routes module not available: %s", exc) except ValueError as exc: if streaming_config is not None: logger.error("RTSP stream routes configured but invalid: %s", exc) raise logger.info("Skipping RTSP stream routes (not configured): %s", exc) except Exception as exc: logger.error("Failed to register RTSP stream routes: %s", exc, exc_info=True) raise # Register video delete routes try: from vss_agents.api.video_delete import register_video_delete_routes logger.info("Adding video delete routes...") register_video_delete_routes(app, self.config) logger.info("Successfully registered video delete routes") except ImportError as exc: logger.debug("Video delete routes module not available: %s", exc) except ValueError as exc: if streaming_config is not None: logger.error("Video delete routes configured but invalid: %s", exc) raise logger.info("Skipping video delete routes (not configured): %s", exc) except Exception as exc: logger.error("Failed to register video delete routes: %s", exc, exc_info=True) raise ================================================ FILE: agent/src/vss_agents/api/health_endpoint.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # NVIDIA CORPORATION, its affiliates and licensors retain all intellectual # property and proprietary rights in and to this material, related # documentation and any modifications thereto. Any use, reproduction, # disclosure or distribution of this material and related documentation # without an express license agreement from NVIDIA CORPORATION or # its affiliates is strictly prohibited. from collections.abc import AsyncGenerator import logging from typing import Any from nat.builder.builder import Builder from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig logger = logging.getLogger(__name__) class HealthEndpointConfig(FunctionBaseConfig, name="health_endpoint"): """Configuration for the health endpoint.""" description: str = "Check if the service is healthy" @register_function(config_type=HealthEndpointConfig) async def health_endpoint(config: HealthEndpointConfig, _: Builder) -> AsyncGenerator[FunctionInfo]: """Health endpoint that returns service status.""" async def _health_endpoint(_: None) -> dict[str, Any]: """ Check if the service is healthy. Returns: dict: Health status with isAlive flag. """ return {"isAlive": True} logger.info(f"{__name__}: health_endpoint registered") # Create a Generic AI-Q tool that can be used with any supported LLM framework yield FunctionInfo.create( single_fn=_health_endpoint, description=config.description, ) ================================================ FILE: agent/src/vss_agents/api/register.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from . import health_endpoint from . import rtsp_stream_api from . import video_upload_url __all__ = ["health_endpoint", "rtsp_stream_api", "video_upload_url"] ================================================ FILE: agent/src/vss_agents/api/rtsp_stream_api.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ RTSP Stream API Ingestion """ from enum import StrEnum import logging import os from typing import Any from fastapi import APIRouter from fastapi import FastAPI import httpx from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field from vss_agents.tools.vst.utils import add_sensor as vst_add_sensor from vss_agents.tools.vst.utils import delete_sensor as vst_delete_sensor from vss_agents.tools.vst.utils import delete_storage as vst_delete_storage from vss_agents.tools.vst.utils import get_rtsp_url as vst_get_rtsp_url from vss_agents.tools.vst.utils import get_stream_info_by_name as vst_get_stream_info_by_name class StreamMode(StrEnum): """Mode for stream processing.""" SEARCH = "search" # search profile: VST + RTVI-CV + RTVI-embed + embedding generation OTHER = "other" # rest other profiles: VST only logger = logging.getLogger(__name__) # ============================================================================ # Configuration # ============================================================================ class ServiceConfig: """Service URLs and settings - initialized once per router.""" def __init__( self, vst_internal_url: str, rtvi_cv_base_url: str = "", rtvi_embed_base_url: str = "", rtvi_embed_model: str = "cosmos-embed1-448p", rtvi_embed_chunk_duration: int = 5, default_stream_mode: str = "search", ): self.vst_url = vst_internal_url.rstrip("/") self.rtvi_cv_url = rtvi_cv_base_url.rstrip("/") if rtvi_cv_base_url else "" self.rtvi_embed_url = rtvi_embed_base_url.rstrip("/") if rtvi_embed_base_url else "" self.rtvi_embed_model = rtvi_embed_model self.rtvi_embed_chunk_duration = rtvi_embed_chunk_duration self.default_stream_mode = StreamMode(default_stream_mode) if default_stream_mode else StreamMode.SEARCH # ============================================================================ # Request/Response Models # ============================================================================ class AddStreamRequest(BaseModel): """Request model for adding an RTSP stream (matches VST API).""" model_config = ConfigDict(populate_by_name=True) sensor_url: str = Field(..., alias="sensorUrl", description="RTSP URL of the stream") name: str = Field(..., description="Name for the sensor/stream") username: str = Field(default="", description="RTSP authentication username") password: str = Field(default="", description="RTSP authentication password") location: str = Field(default="", description="Location information") tags: str = Field(default="", description="Tags for the sensor") class AddStreamResponse(BaseModel): """Response model for add stream operation.""" status: str = Field(..., description="'success' or 'failure'") message: str = Field(..., description="Human-readable status message") error: str | None = Field(None, description="Error details if failed") class DeleteStreamResponse(BaseModel): """Response model for delete stream operation.""" status: str = Field(..., description="'success', 'partial', or 'failure'") message: str = Field(..., description="Human-readable status message") name: str = Field(..., description="The sensor name that was deleted") # ============================================================================ # VST API Wrappers # ============================================================================ async def add_to_vst(config: ServiceConfig, request: AddStreamRequest) -> tuple[bool, str, str | None, str | None]: """ Add stream to VST and fetch the RTSP URL from streams API. Returns: (success, message, sensor_id, rtsp_url) """ # Add sensor using shared util success, msg, sensor_id = await vst_add_sensor( sensor_url=request.sensor_url, name=request.name, username=request.username, password=request.password, location=request.location, tags=request.tags, vst_internal_url=config.vst_url, ) if not success: return False, msg, None, None # After successful add, sensor_id is guaranteed to be set assert sensor_id is not None, "sensor_id should be set after successful VST add" # Fetch RTSP URL using shared util success, msg, rtsp_url = await vst_get_rtsp_url(sensor_id, config.vst_url) if not success: return False, msg, sensor_id, None return True, "OK", sensor_id, rtsp_url async def cleanup_vst_sensor(config: ServiceConfig, sensor_id: str | None) -> tuple[bool, str]: """Delete sensor from VST using shared util.""" return await vst_delete_sensor(sensor_id, config.vst_url) async def cleanup_vst_storage(config: ServiceConfig, sensor_id: str | None) -> tuple[bool, str]: """Delete storage files from VST using shared util.""" return await vst_delete_storage(sensor_id, config.vst_url) async def get_stream_info_by_name(config: ServiceConfig, name: str) -> tuple[bool, str, str | None, str | None]: """ Find stream_id and RTSP URL from VST by camera/sensor name using shared util. Returns: (success, message, stream_id, rtsp_url) """ stream_id, rtsp_url = await vst_get_stream_info_by_name(name, config.vst_url) if stream_id is None: return False, f"Stream with name '{name}' not found in VST", None, None return True, "OK", stream_id, rtsp_url # ============================================================================ # RTVI API Functions # ============================================================================ async def add_to_rtvi_cv( client: httpx.AsyncClient, config: ServiceConfig, sensor_id: str, name: str, sensor_url: str ) -> tuple[bool, str]: """ Add stream to RTVI-CV. Returns: (success, message) """ if not config.rtvi_cv_url: logger.info("RTVI-CV not configured, skipping") return True, "Skipped (not configured)" url = f"{config.rtvi_cv_url}/api/v1/stream/add" payload = { "key": "sensor", "value": { "camera_id": sensor_id, "camera_name": name, "camera_url": sensor_url, "change": "camera_add", "metadata": {"resolution": "1920x1080", "codec": "h264", "framerate": 30}, }, "headers": {"source": "vst"}, } logger.info(f"Adding stream to RTVI-CV: POST {url}") logger.debug(f"Payload: {payload}") try: response = await client.post(url, json=payload) if response.status_code not in (200, 201): error = f"RTVI-CV returned {response.status_code}: {response.text}" logger.error(error) return False, error logger.info(f"RTVI-CV stream registered: {sensor_id}") return True, "OK" except Exception as e: error = f"RTVI-CV request failed: {e!s}" logger.error(error, exc_info=True) return False, error async def add_to_rtvi_embed( client: httpx.AsyncClient, config: ServiceConfig, sensor_id: str, name: str, sensor_url: str ) -> tuple[bool, str, str | None]: """ Add stream to RTVI-embed. Returns: (success, message, rtvi_stream_id) """ if not config.rtvi_embed_url: logger.info("RTVI-embed not configured, skipping") return True, "Skipped (not configured)", sensor_id url = f"{config.rtvi_embed_url}/v1/streams/add" payload = { "streams": [ {"liveStreamUrl": sensor_url, "description": "VST live stream", "sensor_name": name, "id": sensor_id} ] } logger.info(f"Adding stream to RTVI-embed: POST {url}") logger.debug(f"Payload: {payload}") try: response = await client.post(url, json=payload) if response.status_code not in (200, 201): error = f"RTVI-embed returned {response.status_code}: {response.text}" logger.error(error) return False, error, None result = response.json() # Response format: {"streams": [{"id": "...", ...}]} streams = result.get("streams", []) rtvi_stream_id = (streams[0].get("id") if streams else None) or sensor_id logger.info(f"RTVI-embed stream registered: {rtvi_stream_id}") return True, "Success", rtvi_stream_id except Exception as e: error = f"RTVI-embed request failed: {e!s}" logger.error(error, exc_info=True) return False, error, None async def start_embedding_generation( client: httpx.AsyncClient, config: ServiceConfig, stream_id: str ) -> tuple[bool, str]: """ Start embedding generation (fire-and-verify: confirm HTTP 200, then close). Returns: (success, message) """ if not config.rtvi_embed_url: logger.info("RTVI-embed not configured, skipping embedding generation") return True, "Skipped (not configured)" url = f"{config.rtvi_embed_url}/v1/generate_video_embeddings" payload = { "id": stream_id, "model": config.rtvi_embed_model, "stream": True, "chunk_duration": config.rtvi_embed_chunk_duration, } logger.info(f"Starting embedding generation: POST {url}") logger.debug(f"Payload: {payload}") try: # Fire-and-verify: Open SSE connection, verify HTTP 200, then close async with client.stream( "POST", url, json=payload, headers={"Content-Type": "application/json", "Accept": "text/event-stream"}, ) as response: if response.status_code != 200: error_body = await response.aread() error = f"RTVI-embed returned {response.status_code}: {error_body.decode()}" logger.error(error) return False, error # HTTP 200 received - embedding generation has started # RTVI-embed continues processing internally after we close logger.info(f"Embedding generation started for stream {stream_id}") return True, "OK" except Exception as e: error = f"Embedding generation request failed: {e!s}" logger.error(error, exc_info=True) return False, error # ============================================================================ # RTVI Cleanup Functions # ============================================================================ async def cleanup_rtvi_cv( client: httpx.AsyncClient, config: ServiceConfig, sensor_id: str, name: str = "", sensor_url: str = "" ) -> tuple[bool, str]: """Remove stream from RTVI-CV.""" if not config.rtvi_cv_url: return True, "Skipped (not configured)" url = f"{config.rtvi_cv_url}/api/v1/stream/remove" payload = { "key": "sensor", "value": { "camera_id": sensor_id, "camera_name": name, "camera_url": sensor_url, "change": "camera_remove", "metadata": {"resolution": "1920x1080", "codec": "h264", "framerate": 30}, }, "headers": {"source": "vst"}, } logger.info(f"Removing from RTVI-CV: POST {url}") try: response = await client.post(url, json=payload) if response.status_code in (200, 201, 204): logger.info(f"RTVI-CV stream removed: {sensor_id}") return True, "OK" return False, f"RTVI-CV returned {response.status_code}: {response.text}" except Exception as e: return False, str(e) async def cleanup_rtvi_embed_stream( client: httpx.AsyncClient, config: ServiceConfig, stream_id: str | None ) -> tuple[bool, str]: """Remove stream from RTVI-embed.""" if not config.rtvi_embed_url: return True, "Skipped (not configured)" url = f"{config.rtvi_embed_url}/v1/streams/delete/{stream_id}" logger.info(f"Removing from RTVI-embed: DELETE {url}") try: response = await client.delete(url) if response.status_code in (200, 204): logger.info(f"RTVI-embed stream removed: {stream_id}") return True, "OK" return False, f"RTVI-embed returned {response.status_code}: {response.text}" except Exception as e: return False, str(e) async def cleanup_rtvi_embed_generation( client: httpx.AsyncClient, config: ServiceConfig, stream_id: str | None ) -> tuple[bool, str]: """Stop embedding generation in RTVI-embed.""" if not config.rtvi_embed_url: return True, "Skipped (not configured)" url = f"{config.rtvi_embed_url}/v1/generate_video_embeddings/{stream_id}" logger.info(f"Stopping embedding generation: DELETE {url}") try: response = await client.delete(url) if response.status_code in (200, 204): logger.info(f"Embedding generation stopped: {stream_id}") return True, "OK" return False, f"RTVI-embed returned {response.status_code}: {response.text}" except Exception as e: return False, str(e) # ============================================================================ # Router Factory # ============================================================================ def create_rtsp_stream_api_router( vst_internal_url: str, rtvi_cv_base_url: str = "", rtvi_embed_base_url: str = "", rtvi_embed_model: str = "cosmos-embed1-448p", rtvi_embed_chunk_duration: int = 5, default_stream_mode: str = "search", ) -> APIRouter: """Create the RTSP stream API router with fire-and-forget implementation.""" router = APIRouter() config = ServiceConfig( vst_internal_url=vst_internal_url, rtvi_cv_base_url=rtvi_cv_base_url, rtvi_embed_base_url=rtvi_embed_base_url, rtvi_embed_model=rtvi_embed_model, rtvi_embed_chunk_duration=rtvi_embed_chunk_duration, default_stream_mode=default_stream_mode, ) @router.post( "/api/v1/rtsp-streams/add", response_model=AddStreamResponse, response_model_exclude_none=True, summary="Add an RTSP stream", description="Adds stream to VST. If mode='search', also adds to RTVI-CV, RTVI-embed and starts embedding generation.", tags=["RTSP Streams"], ) async def add_stream(request: AddStreamRequest) -> AddStreamResponse: """ Add an RTSP stream. Mode 'search' (default): 1. Add to VST → get sensor_id 2. Add to RTVI-CV 3. Add to RTVI-embed 4. Start embedding generation On failure at any step, previous steps are rolled back. Mode 'other': 1. Add to VST only """ sensor_id = None rtvi_embed_stream_id = None rtvi_cv_added = False rtvi_embed_added = False is_search_mode = config.default_stream_mode == StreamMode.SEARCH logger.info(f"Adding stream '{request.name}' in mode: {config.default_stream_mode.value}") # Step 1: Add to VST and get RTSP URL (uses shared utils) success, msg, sensor_id, rtsp_url = await add_to_vst(config, request) if not success: return AddStreamResponse( status="failure", message=f"Failed at VST: {msg}", error=msg, ) logger.info(f"Added RTSP to VST: {sensor_id} {rtsp_url} successfully") # After successful VST add, sensor_id and rtsp_url are guaranteed to be set assert sensor_id is not None, "sensor_id should be set after successful VST add" assert rtsp_url is not None, "rtsp_url should be set after successful VST add" # For 'other' mode, stop here - VST only if not is_search_mode: return AddStreamResponse( status="success", message=f"Stream '{request.name}' added successfully", error=None, ) # For search mode, use httpx client for RTVI calls async with httpx.AsyncClient(timeout=60.0) as client: # Step 2: Add to RTVI-CV using RTSP URL from VST streams API success, msg = await add_to_rtvi_cv(client, config, sensor_id, request.name, rtsp_url) if not success: # Rollback: cleanup VST sensor and storage await cleanup_vst_sensor(config, sensor_id) await cleanup_vst_storage(config, sensor_id) return AddStreamResponse( status="failure", message=f"Failed at RTVI-CV: {msg}", error=msg, ) rtvi_cv_added = config.rtvi_cv_url != "" # Step 3: Add to RTVI-embed using RTSP URL from VST streams API success, msg, rtvi_embed_stream_id = await add_to_rtvi_embed( client, config, sensor_id, request.name, rtsp_url ) if not success: # Rollback: cleanup RTVI-CV and VST (sensor + storage) if rtvi_cv_added: await cleanup_rtvi_cv(client, config, sensor_id, request.name, rtsp_url) await cleanup_vst_sensor(config, sensor_id) await cleanup_vst_storage(config, sensor_id) return AddStreamResponse( status="failure", message=f"Failed at RTVI-embed: {msg}", error=msg, ) rtvi_embed_added = config.rtvi_embed_url != "" # Step 4: Start embedding generation if rtvi_embed_stream_id is None: rtvi_embed_stream_id = sensor_id success, msg = await start_embedding_generation(client, config, rtvi_embed_stream_id) if not success: # Rollback: cleanup RTVI-embed, RTVI-CV, and VST (sensor + storage) if rtvi_embed_added: await cleanup_rtvi_embed_stream(client, config, rtvi_embed_stream_id) if rtvi_cv_added: await cleanup_rtvi_cv(client, config, sensor_id, request.name, rtsp_url) await cleanup_vst_sensor(config, sensor_id) await cleanup_vst_storage(config, sensor_id) return AddStreamResponse( status="failure", message=f"Failed at embedding generation: {msg}", error=msg, ) # Success return AddStreamResponse( status="success", message=f"Stream '{request.name}' added successfully", error=None, ) @router.delete( "/api/v1/rtsp-streams/delete/{name}", response_model=DeleteStreamResponse, response_model_exclude_none=True, summary="Delete an RTSP stream by name", description="Removes stream from services based on configured mode. 'search' mode deletes from VST, RTVI-CV, RTVI-embed. 'other' mode deletes from VST only.", tags=["RTSP Streams"], ) async def delete_stream(name: str) -> DeleteStreamResponse: """ Delete an RTSP stream from services by camera/sensor name. Mode 'search' (best-effort, continues even if individual steps fail): 1. Find stream_id and RTSP URL from VST by name 2. Stop embedding generation 3. Delete from RTVI-embed 4. Delete from RTVI-CV 5. Delete sensor from VST (VST storage is not deleted in search mode.) Mode 'other': 1. Find stream_id from VST by name 2. Delete sensor from VST 3. Delete storage from VST """ results = [] # Track success/failure for overall status is_search_mode = config.default_stream_mode == StreamMode.SEARCH logger.info(f"Deleting stream by name '{name}' in mode: {config.default_stream_mode.value}") # First, find stream_id and RTSP URL from VST by name (uses shared utils) success, msg, stream_id, rtsp_url = await get_stream_info_by_name(config, name) if not success: logger.error(f"Failed to find stream '{name}': {msg}") return DeleteStreamResponse( status="failure", message=f"Failed to find stream with name '{name}': {msg}", name=name, ) logger.info(f"Found stream_id '{stream_id}' for name '{name}'") if stream_id is None: return DeleteStreamResponse( status="failure", message=f"Found stream '{name}' but stream ID is missing", name=name, ) # --- Search mode only: cleanup RTVI services --- if is_search_mode: async with httpx.AsyncClient(timeout=60.0) as client: # Step 1: Stop embedding generation success, msg = await cleanup_rtvi_embed_generation(client, config, stream_id) results.append(success) logger.info(f"Stop embedding generation: {'OK' if success else msg}") # Step 2: Delete from RTVI-embed success, msg = await cleanup_rtvi_embed_stream(client, config, stream_id) results.append(success) logger.info(f"Delete from RTVI-embed: {'OK' if success else msg}") # Step 3: Delete from RTVI-CV success, msg = await cleanup_rtvi_cv(client, config, stream_id, name=name, sensor_url=rtsp_url or "") results.append(success) logger.info(f"Delete from RTVI-CV: {'OK' if success else msg}") # Delete sensor from VST (uses shared utils) success, msg = await cleanup_vst_sensor(config, stream_id) results.append(success) logger.info(f"Delete VST sensor: {'OK' if success else msg}") # Delete storage from VST for other profiles only (uses shared utils) if not is_search_mode: success, msg = await cleanup_vst_storage(config, stream_id) results.append(success) logger.info(f"Delete VST storage: {'OK' if success else msg}") # Determine overall status all_success = all(results) any_success = any(results) if all_success: status = "success" message = f"Stream '{name}' deleted successfully" elif any_success: status = "partial" message = f"Stream '{name}' partially deleted - some services failed" else: status = "failure" message = f"Failed to delete stream '{name}'" logger.info(f"Delete stream '{name}' completed with status: {status}") return DeleteStreamResponse( status=status, message=message, name=name, ) return router # ============================================================================ # Registration Function # ============================================================================ def register_rtsp_stream_api_routes(app: FastAPI, config: Any) -> None: """ Register RTSP stream API routes to the FastAPI app. Args: app: FastAPI application instance config: NAT Config object containing application configuration """ try: # Look for streaming_ingest config under general.front_end streaming_config = getattr(config.general.front_end, "streaming_ingest", None) if streaming_config: vst_internal_url = getattr(streaming_config, "vst_internal_url", None) or os.getenv("VST_INTERNAL_URL") rtvi_cv_base_url = getattr(streaming_config, "rtvi_cv_base_url", None) or "" rtvi_embed_base_url = getattr(streaming_config, "rtvi_embed_base_url", None) or "" rtvi_embed_model = getattr(streaming_config, "rtvi_embed_model", "cosmos-embed1-448p") rtvi_embed_chunk_duration = getattr(streaming_config, "rtvi_embed_chunk_duration", 5) default_stream_mode = str( getattr(streaming_config, "stream_mode", None) or os.getenv("STREAM_MODE", "search") ) logger.info("Using streaming_ingest config from YAML") else: # Fallback to environment variables host_ip = os.getenv("HOST_IP") vst_internal_url = os.getenv("VST_INTERNAL_URL") rtvi_cv_port = os.getenv("RTVI_CV_PORT", "9000") rtvi_embed_port = os.getenv("RTVI_EMBED_PORT", "8017") rtvi_cv_base_url = f"http://{host_ip}:{rtvi_cv_port}" if host_ip else "" rtvi_embed_base_url = f"http://{host_ip}:{rtvi_embed_port}" if host_ip else "" rtvi_embed_model = "cosmos-embed1-448p" rtvi_embed_chunk_duration = 5 default_stream_mode = os.getenv("STREAM_MODE", "search") logger.info("Using environment variables for configuration") # Validate required fields if not vst_internal_url: raise ValueError("VST_INTERNAL_URL must be set") if not rtvi_embed_base_url: raise ValueError("RTVI-embed URL must be configured (HOST_IP + RTVI_EMBED_PORT or rtvi_embed_base_url)") # Create and register router router = create_rtsp_stream_api_router( vst_internal_url=vst_internal_url, rtvi_cv_base_url=rtvi_cv_base_url, rtvi_embed_base_url=rtvi_embed_base_url, rtvi_embed_model=rtvi_embed_model, rtvi_embed_chunk_duration=rtvi_embed_chunk_duration, default_stream_mode=default_stream_mode, ) app.include_router(router) logger.info(f"RTSP stream API routes registered successfully (default mode: {default_stream_mode})") except Exception as e: logger.error(f"Failed to register RTSP stream API routes: {e}", exc_info=True) raise ================================================ FILE: agent/src/vss_agents/api/video_delete.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Delete video API endpoint. Provides a DELETE endpoint for removing uploaded videos from the system. Supports two modes: - "other" (non-search): Deletes from VST only (sensor + storage). - "search": Deletes from Elasticsearch indexes (embed, behavior, raw), RTVI-CV, and VST (reverse of add flow). """ import logging import os from typing import Any from elasticsearch import AsyncElasticsearch from fastapi import APIRouter from fastapi import FastAPI import httpx from pydantic import BaseModel from pydantic import Field from vss_agents.tools.vst.utils import VSTError from vss_agents.tools.vst.utils import delete_vst_sensor from vss_agents.tools.vst.utils import delete_vst_storage from vss_agents.tools.vst.utils import get_sensor_id_from_stream_id logger = logging.getLogger(__name__) # ============================================================================ # Response Models # ============================================================================ class DeleteVideoResponse(BaseModel): """Response model for delete video operation.""" status: str = Field(..., description="'success', 'partial', or 'failure'") message: str = Field(..., description="Human-readable status message") video_id: str = Field(..., description="The video/sensor ID that was deleted") # ============================================================================ # RTVI-CV Cleanup Helper # ============================================================================ async def _remove_from_rtvi_cv( client: httpx.AsyncClient, rtvi_cv_url: str, sensor_id: str, sensor_name: str ) -> tuple[bool, str]: """ Remove a video stream from RTVI-CV. Args: client: HTTP client rtvi_cv_url: Base RTVI-CV URL (e.g., http://localhost:9000) sensor_id: The sensor UUID sensor_name: The sensor/video name Returns: (success, message) tuple """ if not rtvi_cv_url: logger.info("RTVI-CV not configured, skipping") return True, "Skipped (not configured)" url = f"{rtvi_cv_url}/api/v1/stream/remove" payload = { "key": "sensor", "value": { "camera_id": sensor_id, "camera_name": sensor_name, "camera_url": "", "change": "camera_remove", "metadata": {"resolution": "1920x1080", "codec": "h264", "framerate": 30}, }, "headers": {"source": "vst"}, } logger.info(f"Removing from RTVI-CV: POST {url}") try: response = await client.post(url, json=payload) if response.status_code in (200, 201, 204): logger.info(f"RTVI-CV stream removed: {sensor_id}") return True, "OK" return False, f"RTVI-CV returned {response.status_code}: {response.text}" except Exception as e: logger.error(f"RTVI-CV remove failed: {e}", exc_info=True) return False, str(e) # ============================================================================ # Elasticsearch Cleanup Helper # ============================================================================ async def _delete_es_documents(es_endpoint: str, index_pattern: str, id_value: str, id_field: str) -> tuple[bool, str]: """ Delete all Elasticsearch documents matching a field value. Uses the delete_by_query API to remove all documents where the specified field matches the given value. The field name and ID value vary by index (use .keyword for exact match): - mdx-embed-filtered: field="sensor.id.keyword", value=streamId (UUID) - mdx-behavior: field="sensor.id.keyword", value=sensorName - mdx-raw: field="sensorId.keyword", value=sensorName Args: es_endpoint: Elasticsearch URL (e.g., http://localhost:9200) index_pattern: ES index name (e.g., "mdx-embed-filtered-2025-01-01") id_value: The value to match (either UUID or sensorName) id_field: The ES document field to match against (use .keyword for exact match) Returns: (success, message) tuple """ es_client = AsyncElasticsearch(es_endpoint) try: result = await es_client.delete_by_query( index=index_pattern, body={ "query": { "term": { id_field: id_value, } } }, refresh=True, conflicts="proceed", # Don't fail on version conflicts ) deleted = result.get("deleted", 0) logger.info(f"Deleted {deleted} docs from ES index '{index_pattern}' (field={id_field}, value={id_value})") return True, f"Deleted {deleted} documents" except Exception as e: logger.error(f"ES delete_by_query failed for index '{index_pattern}': {e}", exc_info=True) return False, str(e) finally: await es_client.close() # ============================================================================ # Router Factory # ============================================================================ def create_video_delete_router( vst_internal_url: str, elasticsearch_url: str = "", rtvi_cv_base_url: str = "", es_embed_index: str = "mdx-embed-filtered-2025-01-01", es_behavior_index: str = "mdx-behavior-2025-01-01", es_raw_index: str = "mdx-raw-2025-01-01", stream_mode: str = "search", ) -> APIRouter: """ Create a FastAPI router for video deletion. Args: vst_internal_url: Internal VST URL for API calls elasticsearch_url: Elasticsearch endpoint URL (required for search mode) rtvi_cv_base_url: RTVI-CV service URL (for removing video from RTVI-CV in search mode) es_embed_index: ES index for video embeddings es_behavior_index: ES index for object behavior data es_raw_index: ES index for raw detection data stream_mode: "search" deletes from ES + RTVI-CV + VST; "other" deletes from VST only Returns: APIRouter with the delete video route """ router = APIRouter() vst_url = vst_internal_url.rstrip("/") rtvi_cv_url = rtvi_cv_base_url.rstrip("/") if rtvi_cv_base_url else "" @router.delete( "/api/v1/videos/{video_id}", response_model=DeleteVideoResponse, response_model_exclude_none=True, summary="Delete an uploaded video", description=( "Deletes a video by its sensor/video ID (UUID). " "In 'search' mode, also removes from ES and RTVI-CV. " "In 'other' mode, only removes from VST." ), tags=["Video Management"], ) async def delete_video(video_id: str) -> DeleteVideoResponse: """ Delete a video from the system by sensor/video ID. This endpoint uses a best-effort approach: it continues even if individual steps fail, and reports the overall result as 'success', 'partial', or 'failure'. Non-search mode ('other'): 1. Delete sensor from VST 2. Delete storage from VST Search mode (reverse of add flow): 0. Look up sensorName from VST (before any deletions) 1. Delete from ES embed index (by sensor.id = video_id/UUID) 2. Delete from ES behavior index (by sensor.id = sensorName) 3. Delete from ES raw index (by sensorId = sensorName) 4. Remove from RTVI-CV 5. Delete sensor from VST 6. Delete storage from VST Args: video_id: The sensor/video UUID (e.g., from the upload response) Returns: DeleteVideoResponse with overall status """ results: list[bool] = [] is_search = stream_mode == "search" sensor_name = "" logger.info(f"Deleting video '{video_id}' (mode: {stream_mode})") async with httpx.AsyncClient(timeout=60.0) as client: # --- Step 0: Look up sensorName from VST (search mode only) --- # Must happen BEFORE any deletions, since we need sensorName for ES queries. if is_search: try: sensor_name = await get_sensor_id_from_stream_id(video_id, vst_url) except VSTError as e: logger.warning( "Could not look up sensorName for '%s': %s. ES cleanup for behavior/raw may not work.", video_id, e, ) sensor_name = "" # --- ES cleanup (search mode only, done first to avoid 'not found' issues) --- # Each index uses .keyword for exact match (avoids accidental match on similar names): # - mdx-embed-filtered: sensor.id.keyword = video_id (UUID/streamId) # - mdx-behavior: sensor.id.keyword = sensorName # - mdx-raw: sensorId.keyword = sensorName if is_search and elasticsearch_url: es_index_configs = [ (es_embed_index, "sensor.id.keyword", video_id), (es_behavior_index, "sensor.id.keyword", sensor_name), (es_raw_index, "sensorId.keyword", sensor_name), ] for index_name, field_name, id_value in es_index_configs: if not id_value: logger.warning(f"Skipping ES delete for '{index_name}': no identifier available") continue success, msg = await _delete_es_documents(elasticsearch_url, index_name, id_value, field_name) results.append(success) logger.info(f"Delete from ES '{index_name}': {'OK' if success else msg}") # --- Remove from RTVI-CV (search mode only) --- if is_search: success, msg = await _remove_from_rtvi_cv(client, rtvi_cv_url, video_id, sensor_name) results.append(success) logger.info(f"Remove from RTVI-CV: {'OK' if success else msg}") # --- Delete VST sensor (using shared vst utils) --- success, msg = await delete_vst_sensor(vst_url, video_id) results.append(success) logger.info("Delete VST sensor: %s", "OK" if success else msg) # --- Delete VST storage (using shared vst utils) --- success, msg = await delete_vst_storage(vst_url, video_id) results.append(success) logger.info("Delete VST storage: %s", "OK" if success else msg) # --- Determine overall status --- all_success = bool(results) and all(results) any_success = any(results) if all_success: status = "success" message = f"Video '{video_id}' deleted successfully" elif any_success: status = "partial" message = f"Video '{video_id}' partially deleted - some steps failed" else: status = "failure" message = f"Failed to delete video '{video_id}'" logger.info(f"Delete video '{video_id}' completed with status: {status}") return DeleteVideoResponse( status=status, message=message, video_id=video_id, ) return router # ============================================================================ # Registration Function # ============================================================================ def register_video_delete_routes(app: "FastAPI", config: "Any") -> None: """ Register video delete routes to the FastAPI app. Reads configuration from the YAML config (streaming_ingest section) with fallback to environment variables. Args: app: FastAPI application instance config: NAT Config object containing application configuration """ try: # Look for streaming_ingest config under general.front_end streaming_config = getattr(config.general.front_end, "streaming_ingest", None) if streaming_config: # streaming_ingest found in config (NAT supports extra fields) vst_internal_url = getattr(streaming_config, "vst_internal_url", None) or os.getenv("VST_INTERNAL_URL") raw_elasticsearch_url = getattr(streaming_config, "elasticsearch_url", None) elasticsearch_url = ( raw_elasticsearch_url if isinstance(raw_elasticsearch_url, str) else os.getenv("ELASTIC_SEARCH_ENDPOINT", "") ) rtvi_cv_base_url = getattr(streaming_config, "rtvi_cv_base_url", None) or "" stream_mode = getattr(streaming_config, "stream_mode", None) or os.getenv("STREAM_MODE", "search") logger.info("Using streaming_ingest config from YAML for video delete routes") else: # Fallback to environment variables vst_internal_url = os.getenv("VST_INTERNAL_URL") elasticsearch_url = os.getenv("ELASTIC_SEARCH_ENDPOINT", "") host_ip = os.getenv("HOST_IP") rtvi_cv_port = os.getenv("RTVI_CV_PORT", "9000") rtvi_cv_base_url = f"http://{host_ip}:{rtvi_cv_port}" if host_ip else "" stream_mode = os.getenv("STREAM_MODE", "search") logger.info("Using environment variables for video delete routes") # Validate required fields if not vst_internal_url: raise ValueError("VST_INTERNAL_URL must be set for video delete routes") # Uploaded videos use a fixed timestamp (2025-01-01) so they always land # in these specific indexes. router = create_video_delete_router( vst_internal_url=vst_internal_url, elasticsearch_url=elasticsearch_url, rtvi_cv_base_url=rtvi_cv_base_url, es_embed_index="mdx-embed-filtered-2025-01-01", es_behavior_index="mdx-behavior-2025-01-01", es_raw_index="mdx-raw-2025-01-01", stream_mode=stream_mode or "search", ) app.include_router(router) logger.info(f"Video delete routes registered successfully (mode: {stream_mode})") except Exception as e: logger.error(f"Failed to register video delete routes: {e}", exc_info=True) raise ================================================ FILE: agent/src/vss_agents/api/video_search_ingest.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Custom streaming video ingest endpoint for VSS Search. This bypasses NAT's standard endpoint pattern to support file streaming. """ import json import logging import os from typing import Any import urllib.parse from fastapi import APIRouter from fastapi import FastAPI from fastapi import HTTPException from fastapi import Request import httpx from pydantic import BaseModel from pydantic import Field from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import VSTError from vss_agents.utils.url_translation import rewrite_url_host logger = logging.getLogger(__name__) # Allowed video MIME types - Only MP4 and MKV as supported ALLOWED_VIDEO_TYPES = { "video/mp4", # .mp4 "video/x-matroska", # .mkv } class VideoIngestResponse(BaseModel): """Response for video ingest endpoint.""" message: str = Field(..., description="Status message indicating completion") video_id: str = Field(..., description="The video ID used for storage") filename: str = Field(..., description="The filename returned by VST after upload") chunks_processed: int = Field(default=0, description="Number of chunks processed") def create_streaming_video_ingest_router( vst_internal_url: str, rtvi_embed_base_url: str, rtvi_cv_base_url: str = "", rtvi_embed_model: str = "cosmos-embed1-448p", rtvi_embed_chunk_duration: int = 5, ) -> APIRouter: """ Create a FastAPI router for streaming video ingest. This router handles raw binary data uploads and streams them directly to VST without buffering the entire file in memory/disk. Args: vst_internal_url: Internal VST URL for API calls (required) rtvi_embed_base_url: Base URL for RTVI Embed service (required) rtvi_cv_base_url: Base URL for RTVI-CV service (optional, skipped if empty) rtvi_embed_model: Model name for RTVI embedding generation (default: cosmos-embed1-448p) rtvi_embed_chunk_duration: Chunk duration in seconds for embedding generation (default: 5) Returns: APIRouter with the streaming video ingest route """ router = APIRouter() @router.put( "/api/v1/videos-for-search/{filename}", response_model=VideoIngestResponse, summary="Upload video with streaming (no buffering) to VST", description="Streams video file directly from client to VST without ANY intermediate storage", tags=["Video Ingest"], ) async def stream_video_to_vst( filename: str, request: Request, ) -> VideoIngestResponse: """ This endpoint: 1. Receives raw binary data from request body 2. Streams directly to VST without ANY intermediate storage 3. Call VST to get the timelines of uploaded video 4. Call VST to get the video url 5. Call RTVI Embed to generate embeddings for the video 6. Return the video id and the number of chunks processed Client must send: - Content-Type: allowed video MIME types (mp4, mkv) - Content-Length: - Body: Raw binary video data Args: filename: Name of the video file (from URL path parameter) request: FastAPI Request object for accessing raw stream Returns: VideoIngestResponse with upload status Raises: HTTPException: If upload fails """ # Fixed timestamp as per requirements start_timestamp = "2025-01-01T00:00:00.000Z" # Remove file extension if present to get video ID video_id = filename.rsplit(".", 1)[0] if "." in filename else filename # Construct VST upload URL vst_url = vst_internal_url.rstrip("/") vst_upload_url = f"{vst_url}/vst/api/v1/storage/file/{video_id}/{start_timestamp}" # Get headers from request content_type = request.headers.get("content-type") content_length = request.headers.get("content-length") # Validate Content-Type is present and valid if not content_type: logger.error("Content-Type header is missing") raise HTTPException( status_code=400, detail="Content-Type header is required. Must be a video format (e.g., video/mp4, video/x-matroska)", ) if content_type not in ALLOWED_VIDEO_TYPES: logger.error(f"Unsupported video format: {content_type}") raise HTTPException( status_code=415, detail=f"Unsupported video format: {content_type}. Supported formats: {', '.join(sorted(ALLOWED_VIDEO_TYPES))}", ) logger.debug(f"Content-Type validated: {content_type}") # Validate Content-Length is present if not content_length: logger.error("Content-Length header is required") raise HTTPException(status_code=400, detail="Content-Length header is required") try: content_length_int = int(content_length) if content_length_int == 0: logger.error("Content-Length is 0") raise HTTPException(status_code=400, detail="File is empty") except ValueError as e: logger.error(f"Invalid Content-Length: {content_length}") raise HTTPException(status_code=400, detail="Invalid Content-Length header") from e try: # Stream directly from request to VST # No intermediate storage, only 8KB in memory at a time async with httpx.AsyncClient(timeout=300.0) as client: logger.info(f"Streaming directly from client to VST at {vst_upload_url}") vst_response = await client.put( vst_upload_url, content=request.stream(), headers={"Content-Type": content_type, "Content-Length": content_length}, ) # Check VST response logger.info(f"VST upload response status: {vst_response.status_code}") if vst_response.status_code not in (200, 201): error_msg = f"VST upload failed with status {vst_response.status_code}: {vst_response.text}" logger.error(error_msg) raise HTTPException(status_code=502, detail=f"VST upload failed: {error_msg}") # Parse VST response vst_result = vst_response.json() logger.info(f"VST upload successful - Streamed {content_length_int} bytes") logger.debug(f"VST response body: {vst_result}") # Extract streamId and sensorId from VST response vst_sensor_id = vst_result.get("sensorId") if not vst_sensor_id: error_msg = f"VST response missing 'sensorId' field: {vst_result}" logger.error(error_msg) raise HTTPException(status_code=502, detail=f"VST response invalid: {error_msg}") logger.info(f"VST sensor ID: {vst_sensor_id}") # Extract filename from VST response vst_filename = vst_result.get("filename", filename) logger.info(f"VST filename: {vst_filename}") # Get start and end times for the stream via shared vst timeline util try: timeline_start_time, timeline_end_time = await get_timeline(vst_sensor_id, vst_url) except VSTError as e: logger.error("Timelines API failed for stream %s: %s", vst_sensor_id, e) raise HTTPException(status_code=502, detail=f"Timelines API failed: {e}") from e if not timeline_start_time or not timeline_end_time: error_msg = f"No valid timeline for stream {vst_sensor_id}" logger.error(error_msg) raise HTTPException(status_code=502, detail=error_msg) logger.info( "Timeline for stream %s: start=%s, end=%s", vst_sensor_id, timeline_start_time, timeline_end_time, ) # Call storage API to get the file path using timeline data storage_url = f"{vst_url}/vst/api/v1/storage/file/{vst_sensor_id}/url" storage_params = { "startTime": timeline_start_time, "endTime": timeline_end_time, "container": "mp4", "configuration": json.dumps({"disableAudio": True}), } logger.info(f"Calling Storage API: GET {storage_url}") logger.info(f"Parameters: {storage_params}") storage_response = await client.get(storage_url, params=storage_params) logger.info(f"Storage API response status: {storage_response.status_code}") if storage_response.status_code != 200: error_msg = ( f"Storage API failed with status {storage_response.status_code}: {storage_response.text}" ) logger.error(error_msg) raise HTTPException(status_code=502, detail=f"Storage API failed: {error_msg}") storage_result = storage_response.json() logger.info("Storage API successful") logger.debug(f"Storage response body: {storage_result}") vst_file_path = storage_result.get("videoUrl") if not vst_file_path: error_msg = f"Storage API response missing 'videoUrl' field: {storage_result}" logger.error(error_msg) raise HTTPException(status_code=502, detail=f"Storage API response invalid: {error_msg}") logger.info(f"VST video URL obtained: {vst_file_path}") # Step 3: Add video to RTVI-CV (if configured) rtvi_cv_url = rtvi_cv_base_url.rstrip("/") if rtvi_cv_base_url else "" if rtvi_cv_url: rtvi_cv_add_url = f"{rtvi_cv_url}/api/v1/stream/add" rtvi_cv_payload = { "key": "sensor", "value": { "camera_id": vst_sensor_id, "camera_name": video_id, "camera_url": vst_file_path, "creation_time": start_timestamp, "change": "camera_add", "metadata": {"resolution": "1920x1080", "codec": "h264", "framerate": 30}, }, "headers": {"source": "vst", "created_at": start_timestamp}, } logger.info(f"Adding video to RTVI-CV: POST {rtvi_cv_add_url}") logger.debug(f"Payload: {rtvi_cv_payload}") async with httpx.AsyncClient(timeout=60.0) as rtvi_cv_client: rtvi_cv_response = await rtvi_cv_client.post(rtvi_cv_add_url, json=rtvi_cv_payload) logger.info(f"RTVI-CV response status: {rtvi_cv_response.status_code}") if rtvi_cv_response.status_code not in (200, 201): error_msg = f"RTVI-CV returned {rtvi_cv_response.status_code}: {rtvi_cv_response.text}" logger.error(error_msg) raise HTTPException(status_code=502, detail=f"RTVI-CV add failed: {error_msg}") logger.info(f"RTVI-CV video added: {vst_sensor_id}") else: logger.info("RTVI-CV not configured, skipping") # Step 4: Trigger embedding generation directly with video URL and stream ID rtvi_embed_url = rtvi_embed_base_url.rstrip("/") embedding_url = f"{rtvi_embed_url}/v1/generate_video_embeddings" # Build the url using internal IP since rtvi embed service is running within the same deployment network parsed_vst = urllib.parse.urlparse(vst_internal_url) if not parsed_vst.hostname: raise HTTPException( status_code=500, detail=f"Invalid vst_internal_url format (missing hostname): {vst_internal_url}", ) translated_video_url = rewrite_url_host(vst_file_path, parsed_vst.hostname) logger.info(f"Using internal VST URL for RTVI: {translated_video_url}") embed_request = { "url": translated_video_url, "id": vst_sensor_id, "model": rtvi_embed_model, "creation_time": start_timestamp, "chunk_duration": rtvi_embed_chunk_duration, } logger.info(f"Calling RTVI Embedding API: POST {embedding_url}") logger.info(f"Request body: {embed_request}") async with httpx.AsyncClient(timeout=600.0) as client: embed_response = await client.post( embedding_url, json=embed_request, headers={"accept": "application/json", "Content-Type": "application/json"}, ) logger.info(f"RTVI Embedding API response status: {embed_response.status_code}") if embed_response.status_code != 200: error_msg = ( f"Embedding generation failed with status {embed_response.status_code}: {embed_response.text}" ) logger.error(error_msg) raise HTTPException(status_code=502, detail=f"Embedding generation failed: {error_msg}") embed_result = embed_response.json() logger.info("RTVI Embedding generation successful") logger.debug(f"RTVI response body: {embed_result}") # Extract chunks processed from response chunks_processed = embed_result.get("usage", {}).get("total_chunks_processed", 0) return VideoIngestResponse( message=f"Video {vst_filename} successfully uploaded to VST and embeddings generated", video_id=vst_sensor_id, filename=vst_filename, chunks_processed=chunks_processed, ) except HTTPException: raise except Exception as e: logger.error(f"Error in streaming video ingest: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Internal server error: {e!s}") from e return router # This function will be called by custom FastAPI worker to register the router def register_streaming_routes(app: "FastAPI", config: "Any") -> None: """ Register streaming video ingest routes to the FastAPI app. This function is called by custom FastAPI worker during app initialization. Args: app: FastAPI application instance config: NAT Config object containing application configuration """ try: # Look for streaming_ingest config under general.front_end streaming_config = getattr(config.general.front_end, "streaming_ingest", None) if streaming_config: # streaming_ingest found in config (NAT supports extra fields) vst_internal_url = getattr(streaming_config, "vst_internal_url", None) or os.getenv("VST_INTERNAL_URL") rtvi_embed_base_url = getattr(streaming_config, "rtvi_embed_base_url", None) rtvi_cv_base_url = getattr(streaming_config, "rtvi_cv_base_url", None) or "" rtvi_embed_model = getattr(streaming_config, "rtvi_embed_model", "cosmos-embed1-448p") rtvi_embed_chunk_duration = getattr(streaming_config, "rtvi_embed_chunk_duration", 5) logger.info("Using streaming_ingest config from YAML") else: # Fallback: streaming_ingest not found (NAT strips unknown fields) # Use environment variables vst_internal_url = os.getenv("VST_INTERNAL_URL") host_ip = os.getenv("HOST_IP") rtvi_embed_port = os.getenv("RTVI_EMBED_PORT", "8017") rtvi_cv_port = os.getenv("RTVI_CV_PORT", "9000") rtvi_embed_base_url = f"http://{host_ip}:{rtvi_embed_port}" if host_ip else None rtvi_cv_base_url = f"http://{host_ip}:{rtvi_cv_port}" if host_ip else "" rtvi_embed_model = "cosmos-embed1-448p" rtvi_embed_chunk_duration = 5 logger.info("streaming_ingest not in config, using environment variables") # Log configuration # Validate required fields if not vst_internal_url: logger.error("VST_INTERNAL_URL not set in environment or config") raise ValueError("VST_INTERNAL_URL environment variable must be set") if not rtvi_embed_base_url: logger.error("RTVI Embed URL not configured - HOST_IP and RTVI_EMBED_PORT environment variables required") raise ValueError("HOST_IP and RTVI_EMBED_PORT environment variables must be set") # Create and register router with config router = create_streaming_video_ingest_router( vst_internal_url=vst_internal_url, rtvi_embed_base_url=rtvi_embed_base_url, rtvi_cv_base_url=rtvi_cv_base_url or "", rtvi_embed_model=rtvi_embed_model, rtvi_embed_chunk_duration=rtvi_embed_chunk_duration, ) app.include_router(router) logger.info("Successfully registered streaming video ingest route:") except Exception as e: logger.error(f"Failed to register streaming video ingest route: {e}", exc_info=True) raise ================================================ FILE: agent/src/vss_agents/api/video_upload_url.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging import re from fastapi import HTTPException from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.api_server import ChatRequest from nat.data_models.api_server import ChatResponse from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class VideoUploadURLConfig(FunctionBaseConfig, name="video_upload_url"): """Configuration for the Video Upload URL tool.""" vst_external_url: str = Field( ..., description="The external VST URL for client-facing upload URLs", ) agent_base_url: str = Field( ..., description="The base URL of the agent service (e.g., http://localhost:8000)", ) class VideoUploadURLInput(BaseModel): """Input for the Video Upload URL tool.""" filename: str = Field( ..., description="The name of the video file to be uploaded", min_length=1, ) embedding: bool = Field( default=False, description="Whether to generate URL for video embedding/search ingestion", ) class VideoUploadURLOutput(BaseModel): """Output for the Video Upload URL tool.""" url: str = Field( ..., description="The VST upload URL for the video file with timestamp", ) @register_function(config_type=VideoUploadURLConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def video_upload_url(config: VideoUploadURLConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Video Upload URL tool that provides a VST upload URL for a video file. This tool constructs a URL for uploading a video file to VST storage. """ async def _video_upload_url(video_upload_url_input: VideoUploadURLInput) -> VideoUploadURLOutput: """ Get a VST upload URL for a video file. Args: video_upload_url_input: VideoUploadURLInput containing the filename and embedding flag. Returns: VideoUploadURLOutput containing the upload URL with timestamp or embedding URL. """ try: filename = video_upload_url_input.filename if not filename: raise HTTPException(status_code=400, detail="Filename is required") # Check for any whitespace character in filename if re.search(r"\s", filename): raise HTTPException( status_code=400, detail="Filename cannot contain whitespace. Please rename the file and try again." ) # Remove file extension if present filename_without_ext = filename.rsplit(".", 1)[0] or filename embedding = video_upload_url_input.embedding # If embedding is requested, return the agent URL for video search if embedding: agent_base_url = config.agent_base_url.rstrip("/") url = f"{agent_base_url}/api/v1/videos-for-search/{filename_without_ext}" logger.info(f"Generated video embedding URL: {url}") # ELSE return the VST upload URL else: # Remove trailing slash from base url if present base_url = config.vst_external_url.rstrip("/") # Return fixed timestamp timestamp = "2025-01-01T00:00:00.000Z" # TODO: remove the temp url and use the vst base url from the config # temp_base_url = "http://localhost:30888" # Construct the upload URL url = f"{base_url}/vst/api/v1/storage/file/{filename_without_ext}/{timestamp}" logger.info(f"Generated video upload URL: {url}") return VideoUploadURLOutput(url=url) except Exception as e: logger.error(f"Error generating video upload URL: {e}") raise def _str_input_converter(input: str) -> VideoUploadURLInput: """Convert string input (JSON) to VideoUploadURLInput.""" return VideoUploadURLInput.model_validate_json(input) def _chat_request_input_converter(request: ChatRequest) -> VideoUploadURLInput: """Convert ChatRequest to VideoUploadURLInput from the last message content.""" try: return VideoUploadURLInput.model_validate_json(request.messages[-1].content) except Exception: logger.exception("Error in chat request input converter.") raise def _output_converter(output: VideoUploadURLOutput) -> str: """Convert output to string JSON.""" return output.model_dump_json() def _chat_response_output_converter(response: VideoUploadURLOutput) -> ChatResponse: """Convert output to ChatResponse.""" return ChatResponse.from_string(_output_converter(response)) yield FunctionInfo.create( single_fn=_video_upload_url, description=_video_upload_url.__doc__, input_schema=VideoUploadURLInput, single_output_schema=VideoUploadURLOutput, converters=[ _str_input_converter, _chat_request_input_converter, _output_converter, _chat_response_output_converter, ], ) ================================================ FILE: agent/src/vss_agents/data_models/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from abc import ABC from langchain_core.output_parsers import PydanticOutputParser class ParserMixin(ABC): _output_parser: PydanticOutputParser | None = None @classmethod def get_output_parser(cls) -> PydanticOutputParser: """Get the output parser for the model.""" if not cls._output_parser: cls._output_parser = PydanticOutputParser(pydantic_object=cls) return cls._output_parser ================================================ FILE: agent/src/vss_agents/data_models/vss.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from datetime import datetime import math from typing import Annotated from typing import Any from typing import Literal from pydantic import BaseModel from pydantic import BeforeValidator from pydantic import Field from pydantic import model_validator def float_to_int(v: float | int) -> int: return math.ceil(v) if v is not None else None class MediaInfoOffset(BaseModel): """Media information using offset for files.""" type: Literal["offset"] = Field( default="offset", description="Information about a segment of media with start and end offsets." ) start_offset: Annotated[ int, Field( default=None, description="Segment start offset in seconds from the beginning of the media.", ge=0, le=4000000000, alias=["start", "start_timestamp"], ), BeforeValidator(float_to_int), ] end_offset: Annotated[ int, Field( default=None, description="Segment end offset in seconds from the beginning of the media.", ge=0, le=4000000000, alias=["end", "end_timestamp"], ), BeforeValidator(float_to_int), ] @model_validator(mode="before") @classmethod def validate_start_and_end(cls, data: dict[str, Any]) -> dict[str, Any]: if data.get("start_offset") is None: data["start_offset"] = 0 if data.get("end_offset") is None: data["end_offset"] = 4000000000 return data model_config = { "extra": "forbid", "populate_by_name": True, } # Validate RFC3339 timestamp string def timestamp_validator(v: str, validation_info: Any) -> str: try: # Attempt to parse the RFC3339 timestamp datetime.strptime(v, "%Y-%m-%dT%H:%M:%S.%fZ") except ValueError as e: raise ValueError( f"{validation_info.field_name} be a valid RFC3339 timestamp string", "InvalidParameters", ) from e return v def remove_timezone(dt: datetime | str) -> datetime: """Remove timezone info from datetime objects and handle ISO 8601 with or without microseconds.""" if isinstance(dt, str): try: # Handle 'Z' for UTC and optional microseconds if dt.endswith("Z"): dt = dt[:-1] + "+00:00" parsed_dt = datetime.fromisoformat(dt) except ValueError: # Fallback for other potential formats or re-raise with more context if needed # For now, let's stick to the original error behavior if fromisoformat fails # This could be a place to try the original strptime if fromisoformat is too strict for other cases raise ValueError(f"Timestamp '{dt}' is not a recognized ISO 8601 format.") from None elif isinstance(dt, datetime): parsed_dt = dt else: # Should not happen based on type hints, but good for robustness raise TypeError(f"Expected datetime or string, got {type(dt)}") if parsed_dt.tzinfo: return parsed_dt.replace(tzinfo=None) return parsed_dt ================================================ FILE: agent/src/vss_agents/embed/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from .cosmos_embed import CosmosEmbedClient from .embed import EmbedClient from .rtvi_cv_embed import RTVICVEmbedClient __all__ = ["CosmosEmbedClient", "EmbedClient", "RTVICVEmbedClient"] ================================================ FILE: agent/src/vss_agents/embed/cosmos_embed.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging from typing import override import httpx from vss_agents.embed.embed import EmbedClient logger = logging.getLogger(__name__) class CosmosEmbedClient(EmbedClient): def __init__(self, endpoint: str): self.endpoint = endpoint self.text_embeddings_url = f"{endpoint}/v1/generate_text_embeddings" self.image_embeddings_url = f"{endpoint}/v1/generate_image_embeddings" self.video_embeddings_url = f"{endpoint}/v1/generate_video_embeddings" @override async def get_image_embedding(self, image_url: str) -> list[float]: """Generate embedding for image input""" # Handles base64 data URI and presigned_url format if image_url.startswith("data:image/"): # base64 URI ("data:image/jpeg;base64,...") formatted_input = image_url else: # presigned_url format formatted_input = f"data:image/jpeg;presigned_url,{image_url}" payload = { "input": [formatted_input], "request_type": "query", "encoding_format": "float", "model": "nvidia/cosmos-embed1", } try: timeout = httpx.Timeout(connect=30.0, read=120.0, write=120.0, pool=30.0) async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post(self.image_embeddings_url, json=payload) response.raise_for_status() result = response.json() embedding: list[float] = result["data"][0]["embedding"] return embedding except httpx.HTTPError as e: logger.error(f"Failed to get image embedding: {e}") raise @override async def get_text_embedding(self, text: str) -> list[float]: """Generate embedding for text input""" payload = { "text_input": [text], "model": "cosmos-embed1-448p", } try: timeout = httpx.Timeout(connect=30.0, read=120.0, write=120.0, pool=30.0) async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post(self.text_embeddings_url, json=payload) response.raise_for_status() result = response.json() embeddings: list[float] = result["data"][0]["embeddings"] return embeddings except httpx.HTTPError as e: logger.error(f"Failed to get text embedding: {e}") raise @override async def get_video_embedding(self, video_url: str) -> list[float]: """Generate embedding for video input""" return (await self.get_video_embeddings_from_urls([video_url]))[0] async def get_video_embeddings_from_urls(self, urls: list[str]) -> list[list[float]]: """Generate embeddings for videos from URLs (public or presigned)""" logger.info(f"Generating embeddings for {len(urls)} video chunks via URLs") # Format URLs according to the required format formatted_urls = [f"data:video/mp4;presigned_url,{url}" for url in urls] payload = { "input": formatted_urls, "model": "nvidia/cosmos-embed1", "encoding_format": "float", "request_type": "bulk_video", } logger.info(f"Payload: {payload}") timeout = httpx.Timeout(connect=30.0, read=120.0, write=120.0, pool=30.0) async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post(self.video_embeddings_url, json=payload) response.raise_for_status() result = response.json() # Extract embeddings from response embeddings = [item["embedding"] for item in result["data"]] logger.info(f"Successfully generated {len(embeddings)} embeddings") return embeddings ================================================ FILE: agent/src/vss_agents/embed/embed.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from abc import ABC from abc import abstractmethod class EmbedClient(ABC): """Abstract base class for embedding clients.""" @abstractmethod async def get_image_embedding(self, image_url: str) -> list[float]: """Generate embedding for image input.""" pass @abstractmethod async def get_text_embedding(self, text: str) -> list[float]: """Generate embedding for text input.""" pass @abstractmethod async def get_video_embedding(self, video_url: str) -> list[float]: """Generate embedding for video input.""" pass ================================================ FILE: agent/src/vss_agents/embed/rtvi_cv_embed.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging from typing import cast from typing import override import httpx from vss_agents.embed.embed import EmbedClient logger = logging.getLogger(__name__) class RTVICVEmbedClient(EmbedClient): """RTVI CV embedding client for text embeddings.""" def __init__(self, endpoint: str): """ Initialize RTVI CV embedding client. Args: endpoint: RTVI CV base URL """ self.endpoint = endpoint.rstrip("/") self.text_embeddings_url = f"{self.endpoint}/api/v1/generate_text_embeddings" @override async def get_text_embedding(self, text: str) -> list[float]: """Generate embedding for text input using RTVI CV API.""" payload = { "text_input": text, "model": "", } try: timeout = httpx.Timeout(connect=30.0, read=120.0, write=120.0, pool=30.0) async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post(self.text_embeddings_url, json=payload) response.raise_for_status() result = response.json() # Extract embedding from response # Format 1: {"data": [{"embedding": [...]}]} # Format 2: {"data": [[...]]} if not result.get("data") or not isinstance(result["data"], list) or len(result["data"]) == 0: raise ValueError("RTVI CV response missing or empty 'data' field") embedding_data = result["data"][0] if isinstance(embedding_data, list): return embedding_data elif isinstance(embedding_data, dict) and "embedding" in embedding_data: return cast("list[float]", embedding_data["embedding"]) else: raise ValueError(f"Unexpected embedding data format: {type(embedding_data).__name__}") except httpx.HTTPError as e: logger.error(f"Failed to get text embedding from RTVI CV: {e}") raise except (KeyError, IndexError, TypeError, ValueError) as e: logger.error(f"Failed to parse RTVI CV response: {e}") raise ValueError(f"Invalid RTVI CV response format: {e}") from e @override async def get_image_embedding(self, image_url: str) -> list[float]: """Image embeddings not supported by RTVI CV client.""" raise NotImplementedError("Image embeddings not supported by RTVI CV client") @override async def get_video_embedding(self, video_url: str) -> list[float]: """Video embeddings not supported by RTVI CV client.""" raise NotImplementedError("Video embeddings not supported by RTVI CV client") ================================================ FILE: agent/src/vss_agents/evaluators/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/evaluators/customized_qa_evaluator/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/evaluators/customized_qa_evaluator/evaluate.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging from langchain_core.language_models import BaseChatModel from langchain_core.prompts import PromptTemplate from nat.eval.evaluator.base_evaluator import BaseEvaluator from nat.eval.evaluator.evaluator_model import EvalInputItem from nat.eval.evaluator.evaluator_model import EvalOutputItem from vss_agents.evaluators.utils import ScoreOutputParser from vss_agents.evaluators.utils import invoke_llm_with_retry from vss_agents.evaluators.utils import should_evaluate from vss_agents.evaluators.utils import strip_agent_think_tags logger = logging.getLogger(__name__) # Default QA evaluation prompt for QA tasks DEFAULT_QA_EVAL_PROMPT = PromptTemplate( input_variables=["question", "answer", "reference"], template="""You are an expert evaluator assessing a Question Answering (QA) system's response accuracy. Question Asked: {question} Agent's Answer: {answer} Ground Truth Answer: {reference} EVALUATION TASK: Compare the agent's answer against the ground truth and determine if they are semantically equivalent with a nuanced score between 0.0 and 1.0. EVALUATION CRITERIA: 1. **Factual Correctness**: Does the agent's answer convey the same factual information as the ground truth? - For Yes/No questions: The boolean value must match exactly. - For counting questions: The number must exactly match the ground truth. - For temporal questions: Allow ±5 seconds tolerance for timestamps. - For descriptive questions: Key facts and details must align. 2. **Completeness**: Does the agent's answer include all key information from the ground truth? - Partial answers should receive partial credit. - Additional correct details beyond ground truth are acceptable. 3. **Semantic Equivalence**: Different phrasings of the same answer are acceptable. - "Yes" and "Yes, a worker dropped one box" are equivalent for a Yes/No question. - "60 seconds" and "at the 1 minute mark" are equivalent. - "No" and "The worker is not wearing a safety vest" are equivalent. SCORING GUIDELINES: - 1.0: Perfect match - answer is factually correct and complete - 0.8-0.9: Essentially correct with minor omissions or slight imprecision - 0.6-0.7: Partially correct - captures main point but missing some details - 0.4-0.5: Mixed - some correct elements but significant errors or omissions - 0.2-0.3: Mostly incorrect but shows some understanding - 0.0-0.1: Completely wrong or irrelevant answer IMPORTANT NOTES: - Focus on SEMANTIC correctness, not exact text matching. OUTPUT: Think through your evaluation step by step, then output ONLY a single decimal number (your score from 0.0 to 1.0) on the final line. """, ) class CustomizedQAEvaluator(BaseEvaluator): """ QA Evaluator that uses an LLM judge to compare agent answers against ground truth. This evaluator is designed for QA tasks where: - Questions are asked about video content - Ground truth answers are provided - Semantic equivalence is more important than exact text matching """ def __init__( self, llm: BaseChatModel, max_concurrency: int = 8, custom_prompt: PromptTemplate | None = None, max_retries: int = 2, evaluation_method_id: str = "qa", llm_judge_reasoning: bool = True, ): """ Initialize the QA Evaluator. Args: llm: The LLM to use as a judge max_concurrency: Maximum concurrent evaluations custom_prompt: Optional custom prompt template (must include: question, answer, reference) max_retries: Maximum retry attempts for failed evaluations evaluation_method_id: The method ID to match against dataset's evaluation_method field llm_judge_reasoning: Whether to enable LLM judge reasoning mode """ super().__init__(max_concurrency=max_concurrency, tqdm_desc="Evaluating QA") self.llm = llm self.max_retries = max_retries self.evaluation_method_id = evaluation_method_id self.llm_judge_reasoning = llm_judge_reasoning self.eval_prompt = custom_prompt if custom_prompt is not None else DEFAULT_QA_EVAL_PROMPT self.output_parser = ScoreOutputParser() logger.info(f"Using {'custom' if custom_prompt is not None else 'default'} QA evaluation prompt") logger.info(f"Evaluation method ID: {self.evaluation_method_id}") logger.info(f"LLM judge reasoning: {self.llm_judge_reasoning}") logger.debug("QA evaluator initialized.") async def evaluate_item(self, item: EvalInputItem) -> EvalOutputItem: """ Evaluate a single QA item by comparing agent's answer to ground truth. Args: item: The evaluation input containing question, answer, and reference Returns: EvalOutputItem with score and reasoning """ if not should_evaluate(item, self.evaluation_method_id): logger.info( f"Skipping evaluation for item {item.id} - '{self.evaluation_method_id}' not in evaluation_method" ) return EvalOutputItem( id=item.id, score=None, reasoning=f"Skipped: not marked for {self.evaluation_method_id} evaluation" ) question = item.input_obj # Strip out tags from generated answer generated_answer = strip_agent_think_tags(item.output_obj) reference = ( item.expected_output_obj if hasattr(item, "expected_output_obj") and item.expected_output_obj else "" ) if not reference: logger.warning(f"Item {item.id} marked for QA evaluation but has no ground_truth") return EvalOutputItem( id=item.id, score=0.0, reasoning="Error: marked for QA evaluation but no ground_truth provided" ) # Format the evaluation prompt prompt_text = self.eval_prompt.format( question=question, answer=generated_answer, reference=reference, ) # Build reasoning closure to capture local variables def build_reasoning(eval_result: dict) -> dict: return { "reasoning": eval_result["reasoning"], "question": question, "generated_answer": generated_answer, "ground_truth": reference, } return await invoke_llm_with_retry( llm=self.llm, prompt_text=prompt_text, output_parser=self.output_parser, item_id=item.id, max_retries=self.max_retries, evaluator_name="QA Evaluator", question_preview=question[:50] + "...", build_reasoning=build_reasoning, llm_judge_reasoning=self.llm_judge_reasoning, ) ================================================ FILE: agent/src/vss_agents/evaluators/customized_qa_evaluator/register.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator from nat.builder.builder import EvalBuilder from nat.builder.evaluator import EvaluatorInfo from nat.cli.register_workflow import register_evaluator from nat.data_models.evaluator import EvaluatorBaseConfig from pydantic import Field class CustomizedQAEvaluatorConfig(EvaluatorBaseConfig, name="customized_qa_evaluator"): """Customized QA Evaluator for QA evaluation. This evaluator uses an LLM judge to compare agent answers against ground truth """ llm_name: str = Field(description="LLM to use as a judge for QA evaluation.") evaluation_method_id: str = Field( default="qa", description="The evaluation method ID that this evaluator corresponds to. " "Items in the dataset must have this ID in their 'evaluation_method' field to be evaluated.", ) custom_prompt_template: str | None = Field( default=None, description="Optional custom prompt template for the LLM judge. " "Must include variables: question, answer, reference. " "If not provided, uses the default QA evaluation prompt.", ) max_retries: int = Field( default=2, description="Maximum number of retry attempts for LLM evaluation after the initial attempt.", ) llm_judge_reasoning: bool = Field( default=True, description="Enable LLM judge reasoning mode for evaluation.", ) @register_evaluator(config_type=CustomizedQAEvaluatorConfig) async def register_customized_qa_evaluator( config: CustomizedQAEvaluatorConfig, builder: EvalBuilder ) -> AsyncGenerator[EvaluatorInfo]: """Register the customized QA evaluator.""" from langchain_core.prompts import PromptTemplate from nat.builder.framework_enum import LLMFrameworkEnum from .evaluate import CustomizedQAEvaluator llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) custom_prompt = None if config.custom_prompt_template: custom_prompt = PromptTemplate( input_variables=["question", "answer", "reference"], template=config.custom_prompt_template, ) _evaluator = CustomizedQAEvaluator( llm=llm, max_concurrency=builder.get_max_concurrency(), custom_prompt=custom_prompt, max_retries=config.max_retries, evaluation_method_id=config.evaluation_method_id, llm_judge_reasoning=config.llm_judge_reasoning, ) yield EvaluatorInfo(config=config, evaluate_fn=_evaluator.evaluate, description="Customized QA Evaluator") ================================================ FILE: agent/src/vss_agents/evaluators/customized_trajectory_evaluator/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/evaluators/customized_trajectory_evaluator/evaluate.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Adapted from https://github.com/NVIDIA/NeMo-Agent-Toolkit/blob/e8dbc1574a2ae53e4fdcd92ad75118024ee37047/packages/nvidia_nat_core/src/nat/eval/trajectory_evaluator/evaluate.py; # https://github.com/langchain-ai/langchain/tree/master/libs/langchain/langchain_classic/evaluation/agents import ast import contextlib import json import logging from typing import Any from langchain_core.language_models import BaseChatModel from langchain_core.prompts import PromptTemplate from langchain_core.tools import BaseTool from langchain_core.utils.function_calling import convert_to_openai_function from nat.eval.evaluator.base_evaluator import BaseEvaluator from nat.eval.evaluator.evaluator_model import EvalInputItem from nat.eval.evaluator.evaluator_model import EvalOutputItem from vss_agents.evaluators.utils import ScoreOutputParser from vss_agents.evaluators.utils import invoke_llm_with_retry from vss_agents.evaluators.utils import should_evaluate from vss_agents.evaluators.utils import strip_agent_think_tags logger = logging.getLogger(__name__) class CustomizedTrajectoryEvaluator(BaseEvaluator): def __init__( self, llm: BaseChatModel, tools: list[BaseTool] | None = None, max_concurrency: int = 8, track_agent_selected_tools_only: bool = False, prompt_with_reference: PromptTemplate | None = None, prompt_without_reference: PromptTemplate | None = None, max_retries: int = 2, evaluation_method_id: str = "trajectory", llm_judge_reasoning: bool = True, ): super().__init__(max_concurrency=max_concurrency, tqdm_desc="Evaluating Trajectory") self.llm = llm self.tools = tools self.track_agent_selected_tools_only = track_agent_selected_tools_only self.max_retries = max_retries self.evaluation_method_id = evaluation_method_id self.llm_judge_reasoning = llm_judge_reasoning self.prompt_with_reference = prompt_with_reference self.prompt_without_reference = prompt_without_reference self.output_parser = ScoreOutputParser() logger.info(f"Prompt with reference: {'provided' if self.prompt_with_reference else 'not provided'}") logger.info(f"Prompt without reference: {'provided' if self.prompt_without_reference else 'not provided'}") logger.info(f"Evaluation method ID: {self.evaluation_method_id}") logger.info(f"LLM judge reasoning: {self.llm_judge_reasoning}") logger.debug("Trajectory evaluator initialized.") def _format_tool_schemas(self) -> str: """Get the description of the agent tools including their parameters. Returns: str: The description of the agent tools with schemas. """ if not self.tools: return "No tools available for the agent." formatted_schemas = [] for i, tool in enumerate(self.tools, 1): tool_schema = convert_to_openai_function(tool) tool_desc = ( f"Tool {i}: {tool_schema['name']}\n" f"Description: {tool_schema['description']}\n" f"Parameters: {json.dumps(tool_schema['parameters'], indent=2)}" ) formatted_schemas.append(tool_desc) return "\n\n".join(formatted_schemas) def _extract_tool_calls_from_llm_end(self, llm_end_step: Any) -> list[dict[str, Any]]: """ Extract tool_calls from an LLM_END step's data.output string in the workflow output. Args: llm_end_step: An LLM_END intermediate step Returns: list: List of tool call dictionaries """ # Parse tool calls from data.output string in the workflow output # Format: "\n\nTool calls: [{'name': '...', 'args': {...}, ...}]" if hasattr(llm_end_step, "data") and llm_end_step.data: output = getattr(llm_end_step.data, "output", "") or "" if isinstance(output, str) and "Tool calls:" in output: try: tc_str = output.split("Tool calls:", 1)[1].strip() parsed = ast.literal_eval(tc_str) if isinstance(parsed, list): return parsed except Exception: logger.debug(f"Failed to parse tool calls from data.output: {output[:200]}") return [] def _get_agent_selected_uuids(self, trajectory: list[Any]) -> set[str]: """ Extract UUIDs of tools and LLMs that were part of agent's tool selection. For each LLM_END, sequentially match the tools it called with the next TOOL_ENDs at the same hierarchy level. Matching is done by: - Hierarchy level (parent_id must match) - Tool name (from payload.name) - Sequential order (first unmatched tool after LLM_END) Args: trajectory: Full ordered list of trajectory steps Returns: set: Set of UUIDs for TOOL_END and LLM_END steps that were part of agent's tool selection. """ from nat.data_models.intermediate_step import IntermediateStepType agent_selected_uuids = set() # Process each LLM_END in order for i, step in enumerate(trajectory): if step.event_type != IntermediateStepType.LLM_END: continue tool_calls = self._extract_tool_calls_from_llm_end(step) if not tool_calls: continue llm_parent_id = step.parent_id # This LLM made tool selections, so include it agent_selected_uuids.add(step.UUID) # For each tool call, find the next matching TOOL_END for tool_call in tool_calls: # NIM format: {"function": {"name": "..."}} # OpenAI format: {"name": "..."} tool_name = tool_call.get("function", {}).get("name") or tool_call.get("name") if not tool_name: continue # Find the next TOOL_END after this LLM_END that matches for j in range(i + 1, len(trajectory)): tool_step = trajectory[j] if tool_step.event_type != IntermediateStepType.TOOL_END: continue # Skip if already matched if tool_step.UUID in agent_selected_uuids: continue # TOOL_END must be at same level as LLM_END if tool_step.parent_id != llm_parent_id: continue # Check tool name matches if tool_step.payload.name == tool_name: agent_selected_uuids.add(tool_step.UUID) break # Found match for this tool_call, move to next return agent_selected_uuids async def evaluate_item(self, item: EvalInputItem) -> EvalOutputItem: """ Evaluate a single EvalInputItem and return an EvalOutputItem. """ if not should_evaluate(item, self.evaluation_method_id): logger.info( f"Skipping evaluation for item {item.id} - '{self.evaluation_method_id}' not in evaluation_method" ) return EvalOutputItem( id=item.id, score=None, reasoning=f"Skipped: not marked for {self.evaluation_method_id} evaluation" ) from typing import Any from nat.data_models.intermediate_step import IntermediateStepType import nat.eval.intermediate_step_adapter as adapter_module from nat.eval.intermediate_step_adapter import IntermediateStepAdapter from pydantic import BaseModel # Redefine AgentAction to accept list for multimodal inputs class AgentAction(BaseModel): tool: str tool_input: str | dict[str, Any] | list[Any] # Added list support log: str = "" # Patch permanently - other eval code can also benefit from list support adapter_module.AgentAction = AgentAction intermediate_step_adapter = IntermediateStepAdapter() event_filter = [IntermediateStepType.LLM_END, IntermediateStepType.TOOL_END] question = item.input_obj # Strip out tags from generated answer generated_answer = strip_agent_think_tags(item.output_obj) trajectory = item.trajectory if self.track_agent_selected_tools_only: logger.info("Filtering trajectory to only include agent-selected tools") # Extract UUIDs of agent-selected tools and the LLMs that selected them agent_selected_uuids = self._get_agent_selected_uuids(trajectory) logger.info(f"Found {len(agent_selected_uuids)} agent-selected steps") # Filter trajectory to only include agent-selected tools filtered_trajectory = [] for step in trajectory: if step.event_type in (IntermediateStepType.TOOL_END, IntermediateStepType.LLM_END): # Only keep tools that were agent-selected if step.UUID in agent_selected_uuids: filtered_trajectory.append(step) else: filtered_trajectory.append(step) trajectory = filtered_trajectory logger.info(f"Filtered to {len(trajectory)} steps") # Convert filtered trajectory to agent actions agent_trajectory = intermediate_step_adapter.get_agent_actions(trajectory, event_filter) logger.info(f"After filtering LLM reasoning steps: {len(agent_trajectory)} steps remain") # Extract tool calls with step numbers. # Each LLM reasoning step (contains "Tool calls:") marks the start of a new step. # Tools following the same LLM step are parallel (same step number). structured_tool_calls = [] step_number = 0 for action, output in agent_trajectory: if isinstance(output, str) and "Tool calls:" in str(output): step_number += 1 continue if step_number == 0: step_number = 1 # No LLM step seen yet, default to step 1 params = action.tool_input if isinstance(params, str): with contextlib.suppress(Exception): params = ast.literal_eval(params) structured_tool_calls.append({"step": step_number, "name": action.tool, "params": params}) # Get conversation history from previous turns (multi-turn only) conv_history = [] if hasattr(item, "full_dataset_entry") and item.full_dataset_entry: conv_history = item.full_dataset_entry.get("_conversation_history", []) if not isinstance(conv_history, list): conv_history = [] # Auto-detect: check if this item has trajectory_ground_truth reference = None if hasattr(item, "full_dataset_entry") and item.full_dataset_entry: reference = item.full_dataset_entry.get("trajectory_ground_truth") has_reference = reference is not None if has_reference and self.prompt_with_reference: # Reference mode: compare structured tool calls against ground truth if structured_tool_calls: actual_tool_calls_str = json.dumps(structured_tool_calls, indent=2) else: actual_tool_calls_str = "(no tool calls)" reference_str = json.dumps(reference, indent=2) prompt_text = self.prompt_with_reference.format( question=question, agent_trajectory=actual_tool_calls_str, answer=generated_answer, reference=reference_str, ) elif not has_reference and self.prompt_without_reference: # No-reference mode: evaluate trajectory without ground truth trajectory_str = "\n".join( [ f"Action: {action.tool}\nInput: {action.tool_input}\nObservation: {output}" for action, output in agent_trajectory ] ) if conv_history: history_lines = [] for turn in conv_history: history_lines.append(f"[{turn['turn_id']}] User: {turn['query']}") history_lines.append(f"[{turn['turn_id']}] Assistant: {turn['answer']}") conversation_history_str = "\n".join(history_lines) else: conversation_history_str = "(no previous turns)" prompt_text = self.prompt_without_reference.format( question=question, agent_trajectory=trajectory_str, answer=generated_answer, tool_schemas=self._format_tool_schemas(), conversation_history=conversation_history_str, ) else: mode = "with" if has_reference else "without" raise ValueError( f"Item {item.id} has {'a' if has_reference else 'no'} trajectory_ground_truth " f"but custom_prompt_template_{mode}_reference is not configured. " f"Please add it to the trajectory evaluator config in config.yml." ) # Build reasoning closure to capture local variables def build_reasoning(eval_result: dict) -> dict: return { "reasoning": eval_result["reasoning"], "query": question, "actual_tool_calls": structured_tool_calls, "expected_tool_calls": reference, "final_answer": generated_answer, "trajectory": [(action.model_dump(), output) for action, output in agent_trajectory], "conversation_history": conv_history, "track_agent_selected_tools_only": self.track_agent_selected_tools_only, } return await invoke_llm_with_retry( llm=self.llm, prompt_text=prompt_text, output_parser=self.output_parser, item_id=item.id, max_retries=self.max_retries, evaluator_name="Trajectory Evaluator", question_preview=question[:50] + "...", build_reasoning=build_reasoning, llm_judge_reasoning=self.llm_judge_reasoning, ) ================================================ FILE: agent/src/vss_agents/evaluators/customized_trajectory_evaluator/register.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Adapted from https://github.com/NVIDIA/NeMo-Agent-Toolkit/blob/develop/src/nat/eval/trajectory_evaluator/register.py; # https://github.com/langchain-ai/langchain/tree/master/libs/langchain/langchain_classic/evaluation/agents from collections.abc import AsyncGenerator from nat.builder.builder import EvalBuilder from nat.builder.evaluator import EvaluatorInfo from nat.cli.register_workflow import register_evaluator from nat.data_models.evaluator import EvaluatorBaseConfig from pydantic import Field class CustomizedTrajectoryEvaluatorConfig(EvaluatorBaseConfig, name="customized_trajectory_evaluator"): """Customized Agent Trajectory Evaluation.""" llm_name: str = Field(description="LLM as a judge.") evaluation_method_id: str = Field( default="trajectory", description="The evaluation method ID that this evaluator corresponds to. " "Items in the dataset must have this ID in their 'evaluation_method' field to be evaluated.", ) track_agent_selected_tools_only: bool = Field( default=True, description="If True, only track tools directly selected by the agent, " "excluding tools called internally by other tools.", ) custom_prompt_template_with_reference: str | None = Field( default=None, description="Prompt template used when the dataset item has trajectory_ground_truth. " "Must include variables: question, agent_trajectory, answer, reference. " "If not provided, items with references will be skipped.", ) custom_prompt_template_without_reference: str | None = Field( default=None, description="Prompt template used when the dataset item has no trajectory_ground_truth. " "Must include variables: question, agent_trajectory, answer, tool_schemas, conversation_history. " "If not provided, items without references will be skipped.", ) max_retries: int = Field( default=2, description="Maximum number of retry attempts for LLM evaluation after the initial attempt. " ) llm_judge_reasoning: bool = Field( default=True, description="Enable LLM judge reasoning mode for evaluation.", ) @register_evaluator(config_type=CustomizedTrajectoryEvaluatorConfig) async def register_customized_trajectory_evaluator( config: CustomizedTrajectoryEvaluatorConfig, builder: EvalBuilder ) -> AsyncGenerator[EvaluatorInfo]: from langchain_core.prompts import PromptTemplate from nat.builder.framework_enum import LLMFrameworkEnum from .evaluate import CustomizedTrajectoryEvaluator llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) tools = await builder.get_all_tools(wrapper_type=LLMFrameworkEnum.LANGCHAIN) prompt_with_reference = None if config.custom_prompt_template_with_reference: prompt_with_reference = PromptTemplate( input_variables=["question", "agent_trajectory", "answer", "reference"], template=config.custom_prompt_template_with_reference, ) prompt_without_reference = None if config.custom_prompt_template_without_reference: prompt_without_reference = PromptTemplate( input_variables=["question", "agent_trajectory", "answer", "tool_schemas", "conversation_history"], template=config.custom_prompt_template_without_reference, ) _evaluator = CustomizedTrajectoryEvaluator( llm=llm, tools=tools, max_concurrency=builder.get_max_concurrency(), track_agent_selected_tools_only=config.track_agent_selected_tools_only, prompt_with_reference=prompt_with_reference, prompt_without_reference=prompt_without_reference, max_retries=config.max_retries, evaluation_method_id=config.evaluation_method_id, llm_judge_reasoning=config.llm_judge_reasoning, ) yield EvaluatorInfo(config=config, evaluate_fn=_evaluator.evaluate, description="CustomizedTrajectory Evaluator") ================================================ FILE: agent/src/vss_agents/evaluators/evaluate_patch.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Multi-turn conversation and latency logging support for evaluation. This module provides: 1. Auto-detection of multi-turn items (by "conversation" field) 2. A patch to NAT's EvaluationRun to handle multi-turn conversations 3. A patch to NAT's publish_output to write latency_summary.json alongside other output files 4. A patch to NAT's write_tabular_output to print average latency 5. Support for filtering dataset by evaluation_method using the DATASET_FILTER environment variable Multi-turn items are automatically detected and run with the same conversation_id across all turns, allowing the agent to maintain context. The patch is auto-applied when this module is imported. Dataset Format -------------- To create a multi-turn evaluation item, add a "conversation" field: { "id": "my_multi_turn_001", "query": "[multi-turn]", # placeholder for NAT loading "conversation": [ { "turn_id": "turn_1", "query": "What videos are available?", "ground_truth": "...", "trajectory_ground_truth": ["..."] "evaluation_method": ["trajectory"] }, { "turn_id": "turn_2", "query": "Show me the first one", "ground_truth": "...", "trajectory_ground_truth": ["..."] "evaluation_method": ["qa", "trajectory"] } ] } """ import asyncio import enum import io import json import logging import os from typing import Any from uuid import uuid4 from nat.eval.evaluator.evaluator_model import EvalInputItem from tqdm import tqdm from vss_agents.evaluators.utils import compute_item_latency from vss_agents.evaluators.utils import strip_agent_think_tags logger = logging.getLogger(__name__) class DatasetFilter(enum.StrEnum): ALL = "all" QA = "qa" TRAJECTORY = "trajectory" REPORT = "report" def _get_conversation(dataset_entry: dict) -> list: """ Get the conversation list from a dataset entry, defaulting to [] if not set or invalid. NAT may use pandas to load JSON datasets, which fills missing fields with NaN. This ensures we always return a list. """ conversation = dataset_entry.get("conversation") return conversation if isinstance(conversation, list) else [] def is_multi_turn_item(dataset_entry: dict) -> bool: """Check if a dataset entry is a multi-turn conversation.""" return len(_get_conversation(dataset_entry)) > 0 # NAT Patch: expand multi-turn items before evaluation _patched = False def _expand_multi_turn_items(eval_input_items: list) -> list: """ Expand multi-turn conversation items into individual turn items. Each multi-turn dataset entry (containing a "conversation" list) is split into separate EvalInputItems, one per turn. All turns from the same conversation share a unique _multi_turn_conversation_id so the patch can group and run them sequentially. Single-turn items are passed through unchanged. """ expanded = [] for item in eval_input_items: if item.full_dataset_entry and is_multi_turn_item(item.full_dataset_entry): # Expand multi-turn into individual turns conversation = _get_conversation(item.full_dataset_entry) conv_id = f"multi_turn_{item.id}_{uuid4().hex[:8]}" logger.info(f"Expanding multi-turn item {item.id} into {len(conversation)} turns") for turn_idx, turn in enumerate(conversation): turn_id = turn.get("turn_id", f"turn_{turn_idx + 1}") # Create a new item for this turn turn_item = EvalInputItem( id=f"{item.id}_{turn_id}", input_obj=turn.get("query", ""), output_obj=None, expected_output_obj=turn.get("ground_truth"), trajectory=[], full_dataset_entry={ **turn, "_multi_turn_conversation_id": conv_id, # Marker for the patch }, ) expanded.append(turn_item) else: expanded.append(item) return expanded def _filter_by_dataset_filter(items: list, dataset_filter: list[str]) -> list: """ Filter expanded items to only include those whose evaluation_method overlaps with dataset_filter. For multi-turn conversations, the entire conversation is kept if any turn matches, since turns depend on prior conversation context. Each kept item's evaluation_method is narrowed to only the methods in the filter, so evaluators not in the filter won't run on it. """ if not dataset_filter: return items conv_items: dict[str, list] = {} single_items: list = [] for item in items: conv_id = item.full_dataset_entry.get("_multi_turn_conversation_id") if conv_id: conv_items.setdefault(conv_id, []).append(item) else: single_items.append(item) filtered = [] for item in single_items: eval_methods = item.full_dataset_entry.get("evaluation_method", []) if isinstance(eval_methods, list) and any(m in dataset_filter for m in eval_methods): item.full_dataset_entry["evaluation_method"] = [m for m in eval_methods if m in dataset_filter] filtered.append(item) for _, turns in conv_items.items(): if any( isinstance(t.full_dataset_entry.get("evaluation_method", []), list) and any(m in dataset_filter for m in t.full_dataset_entry["evaluation_method"]) for t in turns ): for turn in turns: methods = turn.full_dataset_entry.get("evaluation_method", []) if isinstance(methods, list): turn.full_dataset_entry["evaluation_method"] = [m for m in methods if m in dataset_filter] filtered.extend(turns) skipped = len(items) - len(filtered) if skipped > 0: logger.info( f"[DATASET_FILTER] Filtered to {len(filtered)} items (skipped {skipped}) for filter={dataset_filter}" ) return filtered _last_avg_latency: float | None = None def _write_latency_summary(evaluation_run: Any, items: list[Any]) -> float | None: """Write latency_summary.json with per-item and average latency to the results directory.""" try: output_dir = evaluation_run.eval_config.general.output_dir output_dir.mkdir(parents=True, exist_ok=True) item_latencies = [] for item in items: latency = compute_item_latency(item) item_latencies.append({"id": item.id, "query": item.input_obj, "latency_seconds": latency}) valid_latencies = [entry["latency_seconds"] for entry in item_latencies if entry["latency_seconds"] is not None] avg_latency = float(round(sum(valid_latencies) / len(valid_latencies), 3)) if valid_latencies else None summary = { "average_latency_seconds": avg_latency, "items": item_latencies, } summary_file = output_dir / "latency_summary.json" with open(summary_file, "w") as f: json.dump(summary, f, indent=2) logger.info(f"Latency summary written to {summary_file}") return avg_latency except Exception: logger.exception("Failed to write latency_summary.json") return None def apply_patch() -> None: """ Apply patch to NAT's EvaluationRun. 1. Expand multi-turn items into individual turns 2. Run turns within a conversation sequentially 3. Set conversation_id on ContextState before each turn so the agent reuses the same LangGraph thread and retains memory across turns 4. Write latency_summary.json with per-item and average latency to the results directory 5. Output the average scoring to the console """ global _patched if _patched: return from nat.eval.evaluate import EvaluationRun _original_run_workflow_local = EvaluationRun.run_workflow_local async def patched_run_workflow_local(self: Any, session_manager: Any) -> None: """Expand multi-turn items, then run turns sequentially within each conversation.""" from nat.builder.context import ContextState # Expand multi-turn items and optionally filter by DATASET_FILTER original_items = self.eval_input.eval_input_items expanded_items = _expand_multi_turn_items(original_items) valid_filters = {f.value for f in DatasetFilter} dataset_filter_env = os.environ.get("DATASET_FILTER", DatasetFilter.ALL.value).strip().lower() dataset_filter = [s.strip() for s in dataset_filter_env.split(",") if s.strip()] invalid = set(dataset_filter) - valid_filters if invalid: raise ValueError( f"Invalid DATASET_FILTER values: {invalid}. Must be one of: {[f.value for f in DatasetFilter]}" ) if DatasetFilter.ALL.value in dataset_filter and len(dataset_filter) > 1: raise ValueError("DATASET_FILTER='all' cannot be combined with other values") if DatasetFilter.ALL.value not in dataset_filter: expanded_items = _filter_by_dataset_filter(expanded_items, dataset_filter) # Group items by conversation_id for sequential execution conv_groups: dict[str, list[Any]] = {} non_multi_turn_items: list[Any] = [] for item in expanded_items: conv_id = item.full_dataset_entry.get("_multi_turn_conversation_id") if conv_id: if conv_id not in conv_groups: conv_groups[conv_id] = [] conv_groups[conv_id].append(item) else: non_multi_turn_items.append(item) total_items = sum(len(items) for items in conv_groups.values()) + len(non_multi_turn_items) pbar = tqdm(total=total_items, desc="Running workflow") # Since we call _original_run_workflow_local per turn, each call creates its own progress bar. # We redirect NAT's tqdm to StringIO to silence them and use a single progress bar above instead. # NAT uses `from tqdm import tqdm` so the name is bound in its module # namespace at import time. We must patch it there directly. import nat.eval.evaluate as _nat_eval_module _original_nat_tqdm = _nat_eval_module.tqdm async def run_conversation(conv_id: str, items: list[Any]) -> None: """Run turns within a single conversation sequentially.""" # Set conversation_id once for this task. asyncio.gather creates # a task per coroutine, each with its own ContextVar copy. ContextState.get().conversation_id.set(conv_id) logger.info(f"[Multi-turn] Running conversation {conv_id} with {len(items)} turns sequentially") conversation_history: list[dict[str, str]] = [] for item in items: # Add previous turns so evaluators have conversation context if conversation_history: item.full_dataset_entry["_conversation_history"] = list(conversation_history) # Re-set conversation_id before each turn ContextState.get().conversation_id.set(conv_id) logger.info(f"[Multi-turn] Set conversation_id={conv_id} for {item.id}") self.eval_input.eval_input_items = [item] await _original_run_workflow_local(self, session_manager) pbar.update(1) conversation_history.append( { "turn_id": item.full_dataset_entry.get("turn_id", f"turn_{len(conversation_history) + 1}"), "query": item.input_obj, "answer": strip_agent_think_tags(item.output_obj), } ) async def run_non_multi_turn() -> None: """Run non-multi-turn items.""" self.eval_input.eval_input_items = non_multi_turn_items await _original_run_workflow_local(self, session_manager) pbar.update(len(non_multi_turn_items)) try: _nat_eval_module.tqdm = lambda *args, **kwargs: _original_nat_tqdm( *args, **{**kwargs, "file": io.StringIO()} ) # Run all conversations in parallel; turns within each are sequential. # Non-multi-turn items also run in parallel alongside conversations. tasks = [run_conversation(conv_id, items) for conv_id, items in conv_groups.items()] if non_multi_turn_items: tasks.append(run_non_multi_turn()) await asyncio.gather(*tasks) finally: _nat_eval_module.tqdm = _original_nat_tqdm pbar.close() # Restore all items for result collection self.eval_input.eval_input_items = expanded_items # Patch publish_output to also write latency_summary.json alongside other output files _original_publish_output = EvaluationRun.publish_output def patched_publish_output(self: Any, *args: Any, **kwargs: Any) -> None: global _last_avg_latency _original_publish_output(self, *args, **kwargs) _last_avg_latency = _write_latency_summary(self, self.eval_input.eval_input_items) # Patch write_tabular_output to print average latency import nat.cli.commands.evaluate as _nat_cli_eval _original_write_tabular_output = _nat_cli_eval.write_tabular_output def patched_write_tabular_output(eval_run_output: Any) -> None: import click _original_write_tabular_output(eval_run_output) if _last_avg_latency is not None: click.echo(f"Average Latency: {_last_avg_latency:.2f}s") EvaluationRun.run_workflow_local = patched_run_workflow_local EvaluationRun.publish_output = patched_publish_output _nat_cli_eval.write_tabular_output = patched_write_tabular_output _patched = True logger.info("Evaluation patch applied") # Auto-apply patch on import apply_patch() ================================================ FILE: agent/src/vss_agents/evaluators/register.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Import evaluate_patch module to auto-apply the patch from . import evaluate_patch # noqa: F401 from .customized_qa_evaluator.register import register_customized_qa_evaluator from .customized_trajectory_evaluator.register import register_customized_trajectory_evaluator from .report_evaluator.register import register_report_evaluator __all__ = [ "register_customized_qa_evaluator", "register_customized_trajectory_evaluator", "register_report_evaluator", ] ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/data_models.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from typing import Optional from pydantic import BaseModel from pydantic import Field class EvaluationScore(BaseModel): section_score: float | None = Field( None, ge=0.0, le=1.0, description="Score between 0.0 and 1.0, or None if failed to score. " ) method: str = Field(..., description="Evaluation method used (e.g., exact_match, llm_judge, skipped)") actual_value: Any | None = Field(None, description="Actual generated value") reference_value: Any | None = Field(None, description="Reference value") error: str | None = Field(default=None, description="Error message if evaluation failed") field_scores: dict[str, Optional["EvaluationScore"]] = Field( default_factory=dict, description="Field scores within this section. " ) @classmethod def from_error( cls, error_message: str, method: str = "unknown", actual_value: Any = None, reference_value: Any = None, field_scores: dict[str, Optional["EvaluationScore"]] | None = None, ) -> "EvaluationScore": """Create an error score (section_score=None) with error message.""" return cls( section_score=None, method=method, actual_value=actual_value, reference_value=reference_value, error=error_message, field_scores=field_scores or {}, ) EvaluationScore.model_rebuild() ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/eval_config_models.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from pydantic import BaseModel from pydantic import Field from pydantic import model_validator class FieldConfig(BaseModel): """Configuration for a single field in the evaluation tree.""" method: str | None = Field(default=None, description="Evaluation method to use.") fields: dict[str, "FieldConfig"] | None = Field( default=None, description="Nested fields for sections. Required if this is a section with explicit fields.", ) allow_dynamic_field_discovery: bool = Field( default=False, description="If true, dynamically discover and evaluate fields not explicitly defined in 'fields'.", ) _methods: set[str] = set() # Private field to cache collected methods model_config = {"extra": "forbid"} @model_validator(mode="after") def validate_and_collect_methods(self) -> "FieldConfig": """Validate field configuration and collect methods.""" # If fields is explicitly provided, it cannot be None or empty if ("fields" in self.model_fields_set) and (self.fields is None or len(self.fields) == 0): raise ValueError("If 'fields' is specified, it must contain at least one field.") # If average is used as method, fields must be specified or allow_dynamic_field_discovery if self.method == "average" and not self.fields and not self.allow_dynamic_field_discovery: raise ValueError( "Method 'average' can only be used for sections with 'fields' or 'allow_dynamic_field_discovery'" ) # Collect methods methods = set() # Collect method for current node if self.method is not None and self.method != "average": methods.add(self.method) else: methods.add("llm_judge") # Use llm_judge by default # Register llm_judge if dynamic discovery is enabled if self.allow_dynamic_field_discovery: methods.add("llm_judge") # Collect from children if self.fields: for field_config in self.fields.values(): methods.update(field_config._methods) self._methods = methods return self class EvalMetricsConfig(BaseModel): """Root configuration for evaluation metrics.""" root: FieldConfig = Field( ..., description="Root node of the evaluation tree.", ) root_key: str = Field( ..., description="The root key name from the original config.", ) methods: set[str] = Field( default_factory=set, description="A set of all methods used in the config tree.", ) model_config = {"extra": "forbid"} @classmethod def from_dict(cls, config: dict[str, Any]) -> "EvalMetricsConfig": """ Create EvalMetricsConfig from a dictionary with single root key. Args: config: Dictionary with exactly one root key Returns: EvalMetricsConfig instance Raises: ValueError: If config doesn't have exactly one root key """ if not isinstance(config, dict): raise ValueError(f"Config must be a dict, got {type(config).__name__}") if len(config) != 1: raise ValueError(f"Config must have exactly one root key, found {len(config)}: {list(config.keys())}") root_key = next(iter(config.keys())) root_value = config[root_key] root_config = FieldConfig(**root_value) return cls(root=root_config, root_key=root_key, methods=root_config._methods) ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/evaluate.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio import json import logging import os from pathlib import Path import re from typing import TYPE_CHECKING from typing import Any from typing import cast from nat.data_models.component_ref import ObjectStoreRef from nat.data_models.evaluator import EvaluatorBaseConfig from nat.eval.evaluator.base_evaluator import BaseEvaluator from nat.eval.evaluator.evaluator_model import EvalInputItem from nat.eval.evaluator.evaluator_model import EvalOutput from nat.eval.evaluator.evaluator_model import EvalOutputItem from pydantic import Field import yaml from vss_agents.evaluators.utils import should_evaluate from vss_agents.utils.markdown_parser import parse_markdown_to_json from .data_models import EvaluationScore from .eval_config_models import EvalMetricsConfig from .eval_config_models import FieldConfig from .field_evaluators import EvaluationMetric if TYPE_CHECKING: from .field_evaluators.llm_judge import LLMJudgeMetric logger = logging.getLogger(__name__) class ExtendedEvalOutputItem(EvalOutputItem): """Extended EvalOutputItem that includes vlm_field_score.""" vlm_field_score: float | None = Field(None, description="VLM field score for this report") class ExtendedEvalOutput(EvalOutput): """Extended EvalOutput that includes average_vlm_field_score.""" average_score: float | None = None average_vlm_field_score: float | None = None eval_output_items: list[ExtendedEvalOutputItem] = Field(default_factory=list) class ReportEvaluatorConfig(EvaluatorBaseConfig, name="report_evaluator"): """Configuration for the report evaluator.""" eval_metrics_config_path: str = Field(..., description="Path to the YAML evaluation metrics configuration file.") reference_base_dir: str = Field(..., description="Path to the reference reports directory.") object_store: ObjectStoreRef = Field(description="Reference to the object store.") evaluation_method_id: str = Field( default="report", description="The evaluation method ID that this evaluator corresponds to. " "Items in the dataset must have this ID in their 'evaluation_method' field to be evaluated.", ) metric_configs: dict[str, dict[str, Any]] = Field( default_factory=dict, description="Configuration for each metric type.", ) report_url_pattern: str = Field( ..., description="Regex pattern to match the report URL in the agent response. First capture group should be the filename.", ) include_vlm_output: bool = Field( default=True, description="Whether to include VLM field score in the evaluation output.", ) vlm_related_fields: list[str] | None = Field( default=None, description="List of section names that are related to VLM output.", ) def _load_eval_metrics_yaml(config_path: str) -> EvalMetricsConfig: """ Load and validate evaluation metrics from YAML file. Returns: Validated EvalMetricsConfig """ path = Path(config_path) if not path.is_absolute(): path = Path.cwd() / path if not path.exists(): raise FileNotFoundError(f"Evaluation metrics config not found: {path}") with open(path) as f: raw_config = yaml.safe_load(f) if not raw_config: raise ValueError(f"Evaluation metrics config at {path} is empty") try: validated_config = EvalMetricsConfig.from_dict(raw_config) except Exception as e: raise ValueError(f"Invalid evaluation metrics config at {path}: {e}") from e logger.info(f"Loaded and validated evaluation metrics from {path}") return validated_config async def _fetch_and_parse_report( object_store_client: Any, response: str, url_pattern: str, camera_id: str | None = None ) -> tuple[dict[str, Any], str]: """ Fetch report from object store and parse to JSON. Args: object_store_client: Object store client response: Generated response containing report URL url_pattern: Regex pattern to extract report URL camera_id: Optional camera ID to construct full path (e.g., "camera_001") Returns: Tuple of (parsed_report, report_url) """ # Extract URL and filename from response url_match = re.search(url_pattern, response) if not url_match: raise ValueError(f"No report URL found in response matching pattern: {url_pattern}") report_url = url_match.group(0) # Full URL for logging filename = ( url_match.group(1) if url_match.lastindex and url_match.lastindex >= 1 else report_url.split("/")[-1] ) # Extract filename from capture group or URL # Construct object store path with camera_id prefix to avoid conflicts object_path = f"{camera_id}/{filename}" if camera_id else filename # Fetch from object store obj = await object_store_client.get_object(object_path) if not obj or not obj.data: raise ValueError(f"Report not found in object store: {object_path}") content = obj.data.decode("utf-8") if isinstance(obj.data, bytes) else obj.data return parse_markdown_to_json(content), report_url class ReportEvaluator(BaseEvaluator): """ Hierarchical report evaluator with two-stage evaluation: 1. Field-level scoring (explicit metrics + dynamic discovery for unspecified fields) 2. Section-level scoring (section treated as a field with dict value) """ def __init__( self, config: EvalMetricsConfig, metric_instances: dict[str, EvaluationMetric], object_store_client: Any, report_url_pattern: str, reference_base_dir: str = "", include_vlm_output: bool = True, vlm_related_fields: list[str] | None = None, max_concurrency: int = 4, evaluation_method_id: str = "report", ) -> None: """ Initialize the report evaluator. Args: config: Validated EvalMetricsConfig metric_instances: Initialized metric instances object_store_client: Object store for fetching reports report_url_pattern: Regex pattern to extract report URL reference_base_dir: Base directory for reference report files (optional; uses cwd if empty) include_vlm_output: Whether to include VLM field score in output vlm_related_fields: List of section names related to VLM output max_concurrency: Max concurrent evaluations evaluation_method_id: The method ID to match against dataset's evaluation_method field """ super().__init__(max_concurrency, tqdm_desc="Evaluating agent generated reports") self.config = config self.metric_instances = metric_instances self.object_store_client = object_store_client self.report_url_pattern = report_url_pattern self.reference_base_dir = reference_base_dir self.include_vlm_output = include_vlm_output self.vlm_related_fields = vlm_related_fields self.evaluation_method_id = evaluation_method_id logger.info(f"Report evaluator initialized with evaluation_method_id: {self.evaluation_method_id}") async def evaluate(self, eval_input_items: list[EvalInputItem]) -> ExtendedEvalOutput: """ Override evaluate to add custom aggregation for VLM field scores. Args: eval_input_items: List of evaluation input items Returns: ExtendedEvalOutput with average_score and average_vlm_field_score """ # Call base evaluate method to get standard output result = await super().evaluate(eval_input_items) # Calculate average VLM field score average_vlm_field_score = None if self.include_vlm_output: vlm_field_scores = [] for item in result.eval_output_items: if hasattr(item, "vlm_field_score"): vlm_field_scores.append(item.vlm_field_score if item.vlm_field_score is not None else 0.0) average_vlm_field_score = sum(vlm_field_scores) / len(vlm_field_scores) if vlm_field_scores else None # Log the results vlm_score_str = f"{average_vlm_field_score:.4f}" if average_vlm_field_score is not None else "N/A" avg_score_str = f"{result.average_score:.4f}" if result.average_score is not None else "N/A" logger.info(f"Evaluation complete: average_score={avg_score_str}, average_vlm_field_score={vlm_score_str}") else: avg_score_str = f"{result.average_score:.4f}" if result.average_score is not None else "N/A" logger.info(f"Evaluation complete: average_score={avg_score_str} (VLM field score disabled)") extended_output = ExtendedEvalOutput( average_score=result.average_score, eval_output_items=result.eval_output_items, average_vlm_field_score=average_vlm_field_score, ) return extended_output async def evaluate_item(self, item: EvalInputItem) -> ExtendedEvalOutputItem: """Evaluate an item from the evaluation dataset.""" if not should_evaluate(item, self.evaluation_method_id): logger.info( f"Skipping evaluation for item {item.id} - '{self.evaluation_method_id}' not in evaluation_method" ) return ExtendedEvalOutputItem( id=item.id, score=None, vlm_field_score=None, reasoning=f"Skipped: not marked for {self.evaluation_method_id} evaluation", ) try: answer = item.expected_output_obj # Reference file path generated_answer = item.output_obj # Generated report reference # Load reference base_dir = Path(self.reference_base_dir) if self.reference_base_dir else Path.cwd() reference_path = base_dir / answer with open(reference_path) as f: reference = json.load(f) # Extract camera_id from reference path camera_id = None camera_match = re.search(r"camera_\d+", str(reference_path)) if camera_match: camera_id = camera_match.group(0) logger.debug(f"Extracted camera_id: {camera_id} from reference path: {reference_path}") # Fetch and parse generated report try: generated, actual_filename = await _fetch_and_parse_report( self.object_store_client, generated_answer, self.report_url_pattern, camera_id ) except ValueError as e: logger.warning(f"Failed to fetch or parse report: {e}. Assigning score 0.") return ExtendedEvalOutputItem( id=item.id, score=0.0, vlm_field_score=0.0 if self.include_vlm_output else None, reasoning={"error": str(e)}, ) # Evaluate the report logger.info( f"Evaluating report {item.id} with reference {reference_path} and generated report {actual_filename}..." ) result = await self.evaluate_tree(reference, generated, self.config.root, path=[self.config.root_key]) # Top level report overall score if result.section_score is None: logger.warning(f"Item {item.id} top-level score is None. Some error occurred during evaluation.") else: logger.info(f"Item {item.id} top-level score: {result.section_score:.3f}") # Calculate VLM field score vlm_field_score = None if self.include_vlm_output and self.vlm_related_fields: vlm_scores = [] for section_name in self.vlm_related_fields: if section_name in result.field_scores and result.field_scores[section_name] is not None: section_eval = result.field_scores[section_name] section_score = section_eval.section_score if section_eval else None if section_score is not None: vlm_scores.append(section_score) logger.info(f"VLM section '{section_name}' score: {section_score:.3f}") else: logger.warning(f"VLM section '{section_name}' has None score, treating as 0.0") vlm_scores.append(0.0) else: logger.warning(f"VLM section '{section_name}' not found in evaluation results") vlm_field_score = sum(vlm_scores) / len(vlm_scores) if vlm_scores else None if vlm_field_score is not None: logger.info(f"Item {item.id} VLM field score: {vlm_field_score:.3f}") else: logger.warning(f"Item {item.id} VLM field score could not be calculated") return ExtendedEvalOutputItem( id=item.id, score=result.section_score, vlm_field_score=vlm_field_score, reasoning={ "sections": result.field_scores, "metadata": {"reference_file": str(reference_path), "actual_file": actual_filename}, }, ) except Exception as e: logger.error(f"Evaluation failed for item {item.id}: {e}", exc_info=True) return ExtendedEvalOutputItem(id=item.id, score=None, vlm_field_score=None, reasoning={"error": str(e)}) async def evaluate_tree(self, reference: Any, actual: Any, config: FieldConfig, path: list[str]) -> EvaluationScore: """ Recursively evaluate a node (field or section) in the report. Args: reference: Reference data at this node actual: Actual generated data at this node config: FieldConfig for this node path: Current path in the tree (for logging) Returns: EvaluationScore for the node """ # Default to llm_judge if no method specified if (method := config.method) is None: method = "llm_judge" logger.debug(f"No method specified for '{'.'.join(path)}', defaulting to llm_judge") explicit_fields = config.fields or {} allow_dynamic_discovery = config.allow_dynamic_field_discovery is_section = bool(explicit_fields or allow_dynamic_discovery) # Evaluate fields within the section field_scores: dict[str, EvaluationScore | None] = {} if is_section: if not isinstance(reference, dict): logger.warning( f"Section '{'.'.join(path)}' expects dict reference but got {type(reference).__name__}. " f"This may indicate a mismatch between config and reference data." ) return EvaluationScore.from_error( error_message=f"Reference at '{'.'.join(path)}' is {type(reference).__name__}, expected dict for section", method=method, actual_value=actual, reference_value=reference, ) actual_dict = actual if isinstance(actual, dict) else {} # 1. Evaluate explicit fields from config if explicit_fields: tasks = [ self.evaluate_tree( reference.get(field_name), actual_dict.get(field_name), explicit_fields[field_name], [*path, field_name], ) for field_name in explicit_fields ] results = await asyncio.gather(*tasks) field_scores.update(zip(explicit_fields.keys(), results, strict=True)) # 2. Evaluate dynamic fields using llm_judge batch evaluation if allow_dynamic_discovery: # See if there are fields in the actual report that do not have an explicit evaluation metric actual_unspecified = set(actual_dict.keys()) - set(explicit_fields) if actual_unspecified: llm_judge = cast("LLMJudgeMetric", self.metric_instances["llm_judge"]) eval_results = await llm_judge.evaluate_with_field_discovery( reference_section=reference, actual_section=actual_dict, unspecified_fields=list(actual_unspecified), ) for field_name, result in eval_results.items(): if result is None: field_scores[field_name] = EvaluationScore.from_error( error_message="LLM failed to score this field during discovery", method="llm_judge_with_field_discovery", actual_value=actual_dict.get(field_name), reference_value=None, ) else: # Extract score and reference_field from LLM result score = result.get("score") reference_field = result.get("reference_field") # Look up reference_value from the reference section if reference_field and reference_field in reference: reference_value = reference[reference_field] elif reference_field: # Reference field specified but not found in reference section reference_value = f"[no matching reference field: {reference_field}]" logger.warning( f"Field '{field_name}': LLM identified reference field '{reference_field}' " f"but it does not exist in the reference section" ) else: # No reference field match found in LLM response reference_value = "[no matching reference field found in LLM response]" logger.debug(f"Field '{field_name}': No matching reference field found response") field_scores[field_name] = EvaluationScore( section_score=score, method="llm_judge_with_field_discovery", actual_value=actual_dict.get(field_name), reference_value=reference_value, ) # Log the dynamic field evaluation result ref_info = f" is matched to {reference_field}" if reference_field else "" logger.info(f"'{'.'.join([*path, field_name])}'{ref_info} and scored {score:.2f}") # Compute score for this node try: if method == "average": if not field_scores: logger.warning(f"'{'.'.join(path)}' uses 'average' method but has no field scores") score = 0.0 else: # Aggregate field scores, treating None as 0.0 scores = [fs.section_score or 0.0 for fs in field_scores.values() if fs is not None] score = sum(scores) / len(scores) logger.info(f"'{'.'.join(path)}' averaged {len(scores)} field scored: {score:.2f}") else: score = await self._score_value(reference, actual, method, path) if score is None: logger.error(f"Evaluation failed for '{'.'.join(path)}': metric returned None") return EvaluationScore.from_error( error_message="Evaluation failed: metric returned None", method=method, actual_value=actual, reference_value=reference, field_scores=field_scores, ) logger.info(f"'{'.'.join(path)}' scored {score:.2f}") except Exception as e: logger.exception(f"Error evaluating '{'.'.join(path)}': {e}") return EvaluationScore.from_error( error_message=str(e), method=method, actual_value=actual, reference_value=reference, field_scores=field_scores, ) return EvaluationScore( section_score=score, method=method, actual_value=actual, reference_value=reference, field_scores=field_scores, ) async def _score_value(self, reference: Any, actual: Any, method: str, path: list[str]) -> float | None: """Score any value using configured metric.""" field_name = path[-1] if path else "" metric = self.metric_instances[method.lower()] # For non-dict values, convert to strings and replace env vars if not isinstance(reference, dict) and not isinstance(actual, dict): reference_str = os.path.expandvars(str(reference) if reference is not None else "") actual_str = str(actual) if actual is not None else "" return await metric.evaluate(actual_str, reference_str, field_name) # For dict values (sections), use metric directly return await metric.evaluate(actual, reference, field_name) ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/field_evaluators/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from . import common from . import llm_judge from .base import METRIC_REGISTRY from .base import EvaluationMetric __all__ = ["METRIC_REGISTRY", "EvaluationMetric"] ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/field_evaluators/base.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from abc import ABC from abc import abstractmethod from collections.abc import Callable import logging from typing import Any logger = logging.getLogger(__name__) METRIC_REGISTRY: dict[str, type["EvaluationMetric"]] = {} def register_metric(name: str) -> Callable[[type["EvaluationMetric"]], type["EvaluationMetric"]]: """ Decorator to register an evaluation metric class. Args: name: Name of the metric (e.g., "f1", "llm_judge") Example: @register_metric("my_metric") class MyMetric(EvaluationMetric): async def evaluate(self, actual, reference, field_name=""): return 1.0 """ def decorator(cls: type["EvaluationMetric"]) -> type["EvaluationMetric"]: metric_name = name.lower() if metric_name in METRIC_REGISTRY: raise ValueError( f"Metric '{metric_name}' is already registered. " f"Cannot overwrite existing metric '{METRIC_REGISTRY[metric_name].__name__}' " f"with '{cls.__name__}'." ) METRIC_REGISTRY[metric_name] = cls logger.debug(f"Registered evaluation metric: {name}") return cls return decorator class EvaluationMetric(ABC): """Base interface for evaluation metrics.""" @abstractmethod async def evaluate(self, actual: Any, reference: Any, field_name: str = "") -> float | None: """ Evaluate actual value against reference. Args: actual: The actual generated value reference: The reference value field_name: Optional field name for context in logging/prompts Returns: Score between 0.0 and 1.0, or None if evaluation fails """ pass ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/field_evaluators/common.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging import re from .base import EvaluationMetric from .base import register_metric logger = logging.getLogger(__name__) def tokenize_text(text: str) -> list[str]: """Tokenize text into lowercase words.""" tokens = re.findall(r"\b\w+\b", text.lower()) return tokens def calculate_f1_score(pred_tokens: list[str], ref_tokens: list[str]) -> float: """Calculate F1 score between predicted and reference tokens.""" if not pred_tokens and not ref_tokens: return 1.0 if not pred_tokens or not ref_tokens: return 0.0 pred_set = set(pred_tokens) ref_set = set(ref_tokens) intersection = pred_set & ref_set if not intersection: return 0.0 precision = len(intersection) / len(pred_set) if pred_set else 0.0 recall = len(intersection) / len(ref_set) if ref_set else 0.0 if precision + recall == 0: return 0.0 f1 = 2 * (precision * recall) / (precision + recall) return f1 @register_metric("non_empty") class NonEmptyMetric(EvaluationMetric): """Accept any non-empty value.""" async def evaluate(self, actual: str, reference: str, field_name: str = "") -> float: # noqa: ARG002 return 1.0 if actual and actual.strip() else 0.0 @register_metric("f1") class F1Metric(EvaluationMetric): """Evaluate using F1 score on tokens.""" async def evaluate(self, actual: str, reference: str, field_name: str = "") -> float: logger.debug(f"Evaluating F1 metric for field {field_name} with actual: {actual} and reference: {reference}") pred_tokens = tokenize_text(actual) ref_tokens = tokenize_text(reference) return calculate_f1_score(pred_tokens, ref_tokens) @register_metric("exact_match") class ExactMatchMetric(EvaluationMetric): """Evaluate using exact string match with normalized whitespace.""" async def evaluate(self, actual: str, reference: str, field_name: str = "") -> float: logger.debug( f"Evaluating exact match metric for field {field_name} with actual: {actual} and reference: {reference}" ) actual_normalized = re.sub(r"\s+", " ", actual.strip()) reference_normalized = re.sub(r"\s+", " ", reference.strip()) return 1.0 if actual_normalized == reference_normalized else 0.0 @register_metric("regex") class RegexMetric(EvaluationMetric): """Evaluate if actual matches the reference regex pattern.""" async def evaluate(self, actual: str, reference: str, field_name: str = "") -> float: logger.debug(f"Evaluating regex metric for field {field_name} with actual: {actual} and reference: {reference}") try: return 1.0 if re.fullmatch(reference, actual) else 0.0 except re.error as e: logger.warning(f"Invalid regex pattern '{reference}': {e}") return 0.0 ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/field_evaluators/llm_judge.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import Callable import json import logging from typing import TYPE_CHECKING from typing import Any from typing import TypeVar from typing import cast from langchain_core.messages import BaseMessage from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage from pydantic import BaseModel from pydantic import Field from pydantic import create_model from vss_agents.utils.reasoning_parsing import parse_reasoning_content from vss_agents.utils.reasoning_utils import get_llm_reasoning_bind_kwargs from vss_agents.utils.reasoning_utils import get_thinking_tag from .base import EvaluationMetric from .base import register_metric if TYPE_CHECKING: from langchain_core.language_models import BaseChatModel logger = logging.getLogger(__name__) T = TypeVar("T") class FieldEvaluation(BaseModel): """Evaluation result for a field.""" score: float = Field(ge=0.0, le=1.0, description="Match score between 0.0 and 1.0") reference_field: str | None = Field(None, description="Matched reference field name. If no match, set to None.") @register_metric("llm_judge") class LLMJudgeMetric(EvaluationMetric): """ LLM judge for evaluating any values (strings, dicts, etc.). Can operate in two modes: 1. Single comparison: Compare two values, return single score 2. Field discovery: Score multiple fields with unspecified metrics, return structured output """ def __init__(self, **kwargs: Any) -> None: """ Initialize LLM judge metric. Expected kwargs: llm: BaseChatModel instance for evaluation (required) single_field_comparison_prompt: Prompt template for comparing one field value (required) multi_field_discovery_prompt: Prompt template for discovering and scoring multiple fields (optional, required only if using dynamic field discovery) max_retries: Maximum retry attempts after initial attempt (default: 2) llm_judge_reasoning: Whether to enable LLM reasoning mode (default: True) """ llm = kwargs.get("llm") if llm is None: raise ValueError("LLM judge metric requires 'llm_name' in config") self.llm: BaseChatModel = llm self.single_field_comparison_prompt = kwargs.get("single_field_comparison_prompt") if self.single_field_comparison_prompt is None: raise ValueError("LLM judge metric requires 'single_field_comparison_prompt' in config") self.multi_field_discovery_prompt = kwargs.get("multi_field_discovery_prompt") self.max_retries = kwargs.get("max_retries", 2) self.llm_judge_reasoning = kwargs.get("llm_judge_reasoning", True) self.thinking_tag = get_thinking_tag(self.llm, self.llm_judge_reasoning) if self.thinking_tag: logger.info(f"LLM Judge: Applying thinking tag: '{self.thinking_tag}' for LLM Judge") llm_kwargs = get_llm_reasoning_bind_kwargs(self.llm, self.llm_judge_reasoning) if llm_kwargs: logger.info(f"LLM Judge: Binding LLM with reasoning kwargs: {llm_kwargs}") self.llm = cast("BaseChatModel", self.llm.bind(**llm_kwargs)) async def _invoke_llm( self, prompt: str, parser: Callable[[str], T], context: str = "", ) -> T: """ Invoke the LLM and parse the response. Args: prompt: The prompt to send to the LLM parser: Function to parse the LLM response text into desired type context: Context string for logging (e.g., field name, operation description) Returns: Parsed result of type T Raises: ValueError: If all retry attempts fail """ last_error = None last_response = None # Build messages with optional thinking tag as system message messages: list[BaseMessage] = [] if self.thinking_tag: messages.append(SystemMessage(content=self.thinking_tag)) messages.append(HumanMessage(content=prompt)) for attempt in range(self.max_retries + 1): try: if attempt > 0: logger.info( f"LLM Judge{f' ({context})' if context else ''}: Invoking LLM (retry {attempt}/{self.max_retries})" ) response = await self.llm.ainvoke(messages) last_response = str(response) logger.debug(f"LLM Judge{f' ({context})' if context else ''}: Response: {response}") _reasoning, actual_content = parse_reasoning_content(response) result = parser(actual_content or "") return result except Exception as e: last_error = e if attempt < self.max_retries: logger.warning( f"LLM Judge{f' ({context})' if context else ''} {'initial attempt' if attempt == 0 else f'retry {attempt}/{self.max_retries}'} failed: {e}. Retrying..." ) raise ValueError( f"LLM failed after {self.max_retries + 1} attempts (1 initial + {self.max_retries} retries){f' ({context})' if context else ''}. " f"Last error: {last_error}. Last response: {last_response}" ) async def evaluate(self, actual: Any, reference: Any, field_name: str = "") -> float | None: """ Evaluate by comparing two values using LLM. Args: actual: Actual generated value reference: Reference value field_name: Field name for context Returns: Score between 0.0 and 1.0, or None if LLM evaluation fails """ # Convert to strings for comparison # If dict, pretty-print as JSON if isinstance(actual, dict): actual_str = json.dumps(actual, indent=2) else: actual_str = str(actual) if not isinstance(actual, str) else actual if isinstance(reference, dict): ref_str = json.dumps(reference, indent=2) else: ref_str = str(reference) if not isinstance(reference, str) else reference field_context = f"\n\nField being evaluated: {field_name}" if field_name else "" # Format the configured prompt if self.single_field_comparison_prompt is None: raise ValueError("single_field_comparison_prompt is not configured") judge_prompt = self.single_field_comparison_prompt.format( field_context=field_context, reference=ref_str, actual=actual_str ) def parse_score(text: str) -> float: """Parse LLM response as a float score.""" score = float(text.strip()) logger.debug(f"LLM Judge score for '{field_name}': {score:.2f}") return score try: return await self._invoke_llm( prompt=judge_prompt, parser=parse_score, context=f"field '{field_name}'" if field_name else "evaluate", ) except Exception: logger.exception(f"LLM evaluation failed for field '{field_name}'. Returning None") return None async def evaluate_with_field_discovery( self, reference_section: dict[str, Any], actual_section: dict[str, Any], unspecified_fields: list[str], ) -> dict[str, dict[str, Any] | None]: """ Score multiple unspecified fields at once using structured outputs. Args: reference_section: Complete reference section actual_section: Complete actual section unspecified_fields: List of field names to score Returns: Dictionary mapping actual field names to evaluation results: {"actual_field_name": {"score": 0.93, "reference_field": "matched_ref_field"}, "another_field": {"score": 0.0, "reference_field": null}} Raises: ValueError: If multi_field_discovery_prompt is not configured """ if not unspecified_fields: return {} if self.multi_field_discovery_prompt is None: raise ValueError( "Cannot use evaluate_with_field_discovery: 'multi_field_discovery_prompt' is required " "when using dynamic field discovery (allow_dynamic_field_discovery=True). " "Please add 'multi_field_discovery_prompt' to your metric_configs for llm_judge." ) reference_fields_json = json.dumps(reference_section, indent=2) # Extract unspecified fields from actual section actual_fields = {k: actual_section[k] for k in unspecified_fields if k in actual_section} actual_fields_json = json.dumps(actual_fields, indent=2) # Dynamically create Pydantic model for these unspecified fields fields_dict: dict[str, Any] = { field_name: (FieldEvaluation, Field(..., description=f"Evaluation for {field_name}")) for field_name in unspecified_fields } DynamicFieldScores = create_model("DynamicFieldScores", **fields_dict) # noqa: N806 structured_llm = self.llm.with_structured_output(DynamicFieldScores) # Format the configured prompt prompt = self.multi_field_discovery_prompt.format( reference_section=reference_fields_json, actual_fields=actual_fields_json ) # Build messages with optional thinking tag as system message messages: list[BaseMessage] = [] if self.thinking_tag: messages.append(SystemMessage(content=self.thinking_tag)) messages.append(HumanMessage(content=prompt)) try: logger.info(f"LLM Judge: Evaluating {len(unspecified_fields)} fields with structured output") result = await structured_llm.ainvoke(messages) logger.info(f"LLM Judge: Result: {result}") # Convert Pydantic model to dict result_dict: dict[str, Any] = {} for field_name in unspecified_fields: try: field_eval = getattr(result, field_name) result_dict[field_name] = { "score": field_eval.score, "reference_field": field_eval.reference_field, } except AttributeError: logger.warning(f"Missing field '{field_name}' in structured output") result_dict[field_name] = None scored_count = sum(1 for v in result_dict.values() if v is not None) logger.info(f"LLM Judge: Successfully scored {scored_count}/{len(unspecified_fields)} fields") return result_dict except Exception as e: logger.exception( f"LLM field discovery failed: {e}. Returning None for all {len(unspecified_fields)} fields" ) return dict.fromkeys(unspecified_fields) ================================================ FILE: agent/src/vss_agents/evaluators/report_evaluator/register.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging from nat.builder.builder import EvalBuilder from nat.builder.evaluator import EvaluatorInfo from nat.builder.framework_enum import LLMFrameworkEnum from nat.cli.register_workflow import register_evaluator from .evaluate import ReportEvaluator from .evaluate import ReportEvaluatorConfig from .evaluate import _load_eval_metrics_yaml from .field_evaluators import METRIC_REGISTRY logger = logging.getLogger(__name__) @register_evaluator(config_type=ReportEvaluatorConfig) async def register_report_evaluator( config: ReportEvaluatorConfig, builder: EvalBuilder ) -> AsyncGenerator[EvaluatorInfo]: """Register the report evaluator with NAT.""" object_store_client = await builder.get_object_store_client(config.object_store) eval_metrics_config = _load_eval_metrics_yaml(config.eval_metrics_config_path) # Collect all unique methods from validated config unique_methods = eval_metrics_config.methods logger.info(f"Collected unique methods: {unique_methods}") # Validate and initialize each metric metric_instances = {} for method_name in unique_methods: # Validate method exists in registry if method_name not in METRIC_REGISTRY: available_metrics = ", ".join(sorted(METRIC_REGISTRY.keys())) raise ValueError(f"Unknown metric '{method_name}' found in config. Available metrics: {available_metrics}") metric_class = METRIC_REGISTRY[method_name] metric_config = config.metric_configs.get(method_name, {}).copy() # If llm_name is present, load the LLM using NAT builder if "llm_name" in metric_config: llm = await builder.get_llm(metric_config["llm_name"], wrapper_type=LLMFrameworkEnum.LANGCHAIN) metric_config["llm"] = llm logger.info(f"Loaded LLM '{metric_config['llm_name']}' for metric '{method_name}'") del metric_config["llm_name"] metric_instances[method_name] = metric_class(**metric_config) logger.info(f"Initialized metric: {method_name}") report_evaluator = ReportEvaluator( config=eval_metrics_config, metric_instances=metric_instances, object_store_client=object_store_client, report_url_pattern=config.report_url_pattern, reference_base_dir=config.reference_base_dir, include_vlm_output=config.include_vlm_output, vlm_related_fields=config.vlm_related_fields, max_concurrency=builder.get_max_concurrency(), evaluation_method_id=config.evaluation_method_id, ) yield EvaluatorInfo(config=config, evaluate_fn=report_evaluator.evaluate, description="Report Evaluator") ================================================ FILE: agent/src/vss_agents/evaluators/utils.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Shared utilities for evaluators.""" from collections.abc import Callable import logging import re from typing import Any from typing import cast from langchain_core.exceptions import OutputParserException from langchain_core.language_models import BaseChatModel from langchain_core.messages import BaseMessage from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage from nat.eval.evaluator.evaluator_model import EvalInputItem from nat.eval.evaluator.evaluator_model import EvalOutputItem from vss_agents.utils.reasoning_parsing import parse_reasoning_content from vss_agents.utils.reasoning_utils import get_llm_reasoning_bind_kwargs from vss_agents.utils.reasoning_utils import get_thinking_tag logger = logging.getLogger(__name__) def compute_item_latency(item: EvalInputItem) -> float | None: """ Compute the wall-clock latency for an evaluation item from its trajectory timestamps. Returns the time in seconds between the first and last event, or None if no trajectory. """ if not item.trajectory: return None try: min_ts = min(step.event_timestamp for step in item.trajectory) max_ts = max(step.event_timestamp for step in item.trajectory) return float(round(max_ts - min_ts, 3)) except Exception: return None def should_evaluate(item: EvalInputItem, evaluator_type: str) -> bool: """ Check if an item should be evaluated by the specified evaluator type. Args: item: The evaluation input item evaluator_type: The evaluation method ID Returns: bool: True if the item should be evaluated, False otherwise Raises: ValueError: If evaluation_method field is missing from the dataset entry """ if not hasattr(item, "full_dataset_entry") or item.full_dataset_entry is None: raise ValueError(f"Item {item.id} missing full_dataset_entry - cannot determine evaluation_method") eval_methods = item.full_dataset_entry.get("evaluation_method", None) if eval_methods is None: raise ValueError( f"Item {item.id} missing required 'evaluation_method' field. " f'Must be a list like ["qa"], ["trajectory"], ["report"], or ["qa", "trajectory", "report"]' ) if not isinstance(eval_methods, list): raise ValueError( f"Item {item.id} has invalid 'evaluation_method' field: {eval_methods}. " f'Must be a list like ["qa"], ["trajectory"], ["report"], or ["qa", "trajectory", "report"]' ) return evaluator_type in eval_methods class ScoreOutputParser: """ Output parser that extracts a score (0.0-1.0) and reasoning from LLM responses. Handles reasoning content in various formats including thinking tags. """ def parse(self, response: Any) -> dict: """ Parse the LLM output to extract score and reasoning. Args: response: The LLM response (can be string or AIMessage) Returns: dict: Contains 'score' (float) and 'reasoning' (str) Raises: OutputParserException: If score cannot be extracted or is invalid """ thinking_content, actual_content = parse_reasoning_content(response) reasoning = thinking_content if thinking_content else "" # Extract score from actual_content if not actual_content: raise OutputParserException(f"No actual content found. Expected score. Full text: {str(response)[:300]}") # Extract the number from actual_content score_match = re.search(r"([0-9]+\.?[0-9]*)", actual_content.strip()) if not score_match: raise OutputParserException( f"Could not extract score from output. Expected a number between 0.0 and 1.0. " f"Got: {actual_content[:200]}" ) try: score = float(score_match.group(1)) # Ensure score is between 0.0 and 1.0 if not (0.0 <= score <= 1.0): raise OutputParserException(f"Score must be between 0.0 and 1.0, got: {score}") except ValueError as e: raise OutputParserException(f"Could not convert score to float: {score_match.group(1)}") from e return {"score": score, "reasoning": reasoning} def strip_agent_think_tags(text: str) -> str: """ Remove ... blocks from text. Args: text: The text to clean Returns: str: Text with agent-think blocks removed """ if not text: return "" # Remove all ... blocks cleaned_text = re.sub(r".*?", "", text, flags=re.DOTALL) # Remove any extra whitespace left behind return cleaned_text.strip() async def invoke_llm_with_retry( llm: BaseChatModel, prompt_text: str, output_parser: ScoreOutputParser, item_id: str, max_retries: int, evaluator_name: str, question_preview: str, build_reasoning: Callable[[dict], dict], llm_judge_reasoning: bool = True, ) -> EvalOutputItem: """ Invoke LLM with retry logic and parse the response. Args: llm: The LLM to invoke prompt_text: The formatted prompt to send output_parser: Parser to extract score from response item_id: ID for the evaluation item max_retries: Maximum number of retry attempts after initial attempt evaluator_name: Name of the evaluator (for logging) question_preview: Preview of the question (for logging) build_reasoning: Callback to build reasoning dict from eval_result llm_judge_reasoning: Whether to enable LLM judge reasoning mode Returns: EvalOutputItem with score and reasoning """ last_error = None last_response = None # Get the thinking tag based on the LLM model thinking_tag = get_thinking_tag(llm, llm_judge_reasoning) if thinking_tag: logger.info(f"Applying thinking tag: '{thinking_tag}' for LLM Judge") # Bind LLM with reasoning kwargs if applicable llm_kwargs = get_llm_reasoning_bind_kwargs(llm, llm_judge_reasoning) if llm_kwargs: logger.info(f"Binding LLM with reasoning kwargs: {llm_kwargs}") llm = cast("BaseChatModel", llm.bind(**llm_kwargs)) # Build messages with optional thinking tag as system message messages: list[BaseMessage] = [] if thinking_tag: messages.append(SystemMessage(content=thinking_tag)) messages.append(HumanMessage(content=prompt_text)) for attempt in range(max_retries + 1): try: if attempt > 0: logger.info( f"{evaluator_name}: Retrying evaluation for question '{question_preview}' " f"(retry {attempt}/{max_retries})" ) # Invoke the LLM with messages response = await llm.ainvoke(messages) last_response = str(response) logger.debug(f"{evaluator_name}: Response: {response}") # Parse the response eval_result = output_parser.parse(response) reasoning = build_reasoning(eval_result) return EvalOutputItem(id=item_id, score=eval_result["score"], reasoning=reasoning) except Exception as e: last_error = e last_response = str(e) if attempt < max_retries: logger.warning( f"{evaluator_name}: " f"{'Initial attempt' if attempt == 0 else f'Retry {attempt}/{max_retries}'} " f"failed for question '{question_preview}': {e}. Retrying..." ) else: logger.exception( f"{evaluator_name}: All retry attempts exhausted for question '{question_preview}'. Error: {e}" ) return EvalOutputItem( id=item_id, score=0.0, reasoning=f"Error evaluating after {max_retries + 1} attempts. " f"Last error: {last_error}. Last response: {last_response}", ) ================================================ FILE: agent/src/vss_agents/prompt.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Prompt constants used by VSS tools.""" VLM_PROMPT_EXAMPLES = [ "You are a warehouse monitoring system. Describe the events in this warehouse and look for any anomalies. " "You are an intelligent traffic system. You must monitor and take note of all traffic related events." ] VLM_FORMAT_INSTRUCTION = """ DON'T MAKE UP ANYTHING THAT NOT FROM THE VIDEO. DON'T HALLUCINATE ANYTHING. Start and end each caption with the timestamp in pts format(presentation timestamp), for example, " <10.5> event_description <11.5> ". """ INIT_SUMMARIZE_PROMPT = { "prompt": "Write a concise and clear dense caption for the provided video.", "caption_summarization_prompt": "Aggregate the following captions in the format **start_timestamp-end_timestamp**event_description. If any two adjacent end_timestamp1 and start_timestamp2 is within a few tenths of a second, and the event_description creates a continuous scene, merge the captions in the format **start_timestamp1-end_timestamp2**event_description. You MUST make sure the timestamp range enclose the entire event.", "summary_aggregation_prompt": "Aggregate the following captions in the format **start_timestamp-end_timestamp**event_description. If any two adjacent end_timestamp1 and start_timestamp2 is within a few tenths of a second, and the event_description creates a continuous scene, merge the captions in the format **start_timestamp1-end_timestamp2**event_description", } VIDEO_FRAME_TIMESTAMP_PROMPT = """ Get the timestamp from this image, timestamp format: 2024-05-30T01:41:25.000Z. IMPORTANT: only output the timestamp! """ VSS_SUMMARIZE_PROMPT = """ You are an expert in VLM, Vision Language Model and have a deep understanding of how to write a prompt that will be given to a vision language model. The vision language model is capable of taking in images and a text prompt and returning a text response. You need to come up with a prompt that can be given to the vision language model so it knows what to look for in the image based on what the user is asking for. Return the suggested prompt in quotes. Do not use quotes in any other way. The user's query is: {user_query} The user's intent is: {user_intent} For different intents, there are several templates you can follow: ## search: user_query: "was there a person wearing a black jacket involved in the accident?" output: "Write a dense caption for the video, focusing on person wearing a black jacket, accident, person involved in the accident" user_query: "person wearing a red jacket" output: "Write a dense caption for the video, focusing on person, and the details of the attire" user_query: "box being dropped" output: "Write a dense caption for the video, focusing on box, movement of the box, and whether box is being dropped" ## root_cause: user_query: "what caused the fight?" output: "Write a dense caption for the video, focusing on the fight and any incidents that could directly lead to a fight.looking for notable interactions, escalations, or disturbances involving individuals who might be involved in the subsequent fight. Specifically look for any instances of: * Verbal disputes or arguments (describe who is involved, body language). * Physical provocations or unwanted touch. * Individuals displaying clear signs of anger, frustration, or distress. * One individual persistently trying to engage with another who seems unwilling. * Gatherings or escalations of tension. For each instance, provide: * Description of individuals involved (clothing, general appearance). * Detailed description of the action/behavior. * The reaction of other individuals involved or nearby." user_query: "There is an explosion on the highway at 01:00. Investigate and report what happened?" output: "Write a dense caption for the video, focusing on the explosion and any incidents that could have led to an explosion. Specifically look for: 1. **Vehicles Involved:** Identify all vehicles visible in the scene. Note their type (e.g., car, truck, tanker), color, and direction of travel. 2. **Traffic Conditions:** Describe the flow of traffic. Is it free-flowing, congested, or stopped (traffic jam)? Note any sudden stops or changes in traffic speed. 3. **Initial Incidents:** Look for any collisions, fires (even small ones), spills of liquids (especially from trucks or tankers), or unusual behavior of vehicles. 4. **Escalation:** If an initial incident is detected, track its progression. Does a fire grow? Does damage to a vehicle worsen? Is there a release of any substance?" ## detailed_report: user_query: "there are some people chasing each other at 00:09 at camera 3. What happened?" output: "Write a dense caption for the video, focusing on people chasing each other. Specifically looking for: - People involved — including clothing, posture, and identifiable features - Key actions and interactions — such as who does what, to whom, and in what order - Location context — where the events take place, including landmarks, environment, and time of day - Object relationships — such as vehicles, buildings, or signs in proximity to people or actions - Scene progression — the sequence of events and any escalation or movement Use full sentences and identify each person or object clearly based on appearance or location. Do not leave out any critical details. ## search: user_query: "There is a robber seen at camera a_4, 00:05, where is the criminal?" output: "you are an expert in video surveillance, and write a dense caption for the images from the video, look for instances of people that may be doing something odd, include every persons clothing, appearance behavior, things they are carrying and actions in detail, so that every person is clearly identifiable and an act of robbery is reasoned. Remember, suspect everyone as robbery is a nuanced action. actions can be snatching someone's object, picking up something, etc. Specifically look for any changes in the things people are carrying between different image samples to deduce robbery" # Output format: ONLY return the generated prompt, do not include any other text. Your output: """ ================================================ FILE: agent/src/vss_agents/py.typed ================================================ ================================================ FILE: agent/src/vss_agents/tools/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/tools/attribute_search.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio from collections.abc import AsyncGenerator from copy import deepcopy from datetime import datetime from datetime import timedelta import logging import re from typing import Any from typing import cast from elasticsearch import AsyncElasticsearch from elasticsearch import NotFoundError as ESNotFoundError from nat.builder.builder import Builder from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function # from nat.data_models.component_ref import FunctionRef # type: ignore[import-untyped] # NOTE: Unused - video_clip_tool removed from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.embed.embed import EmbedClient from vss_agents.embed.rtvi_cv_embed import RTVICVEmbedClient from vss_agents.tools.vst.snapshot import build_screenshot_url logger = logging.getLogger(__name__) # Base timestamps for video offset conversion (same as embed_search) BASE_2025 = datetime(2025, 1, 1, tzinfo=datetime.now().astimezone().tzinfo) # Minimum clip duration in seconds (for attribute-only search results) # Clips shorter than this will be extended to this duration MIN_CLIP_DURATION_SECONDS = 1.0 class AttributeSearchInput(BaseModel): """Input for attribute-based search""" query: str | list[str] = Field( ..., description="Attribute query or list of queries (e.g., 'person with red hat' or ['person', 'red hat'])", ) source_type: str = Field( default="video_file", description="Type of video source: 'video_file' for uploaded videos, 'rtsp' for live/camera streams.", ) timestamp_start: datetime | None = Field( default=None, description="Start time filter", ) timestamp_end: datetime | None = Field( default=None, description="End time filter", ) video_sources: list[str] | None = Field( default=None, description="Filter by video source names (supports wildcard matching). Can be used for both video source names and sensor IDs.", ) top_k: int = Field( default=1, description="Number of results to return", ) min_similarity: float = Field( default=0.3, description="Minimum cosine similarity threshold", ) fuse_multi_attribute: bool = Field( default=True, description="If True, fuse multiple attributes (combine object IDs for single screenshot). If False, append top_k results per attribute independently (no fusion).", ) exclude_videos: list[dict[str, str]] = Field( default_factory=list, description="List of videos to exclude from results" ) class AttributeSearchMetadata(BaseModel): """Metadata for attribute search result""" sensor_id: str = Field(..., description="Sensor/camera ID") object_id: str = Field(..., description="Object ID") object_type: str = Field(..., description="Object type") frame_timestamp: str = Field(..., description="Best frame timestamp") start_time: str | None = Field(None, description="Start time of the time range (earliest from duplicates)") end_time: str | None = Field(None, description="End time of the time range (latest from duplicates)") bbox: dict[str, Any] | None = Field(None, description="Bounding box dimensions") behavior_score: float = Field(..., description="Behavior-level similarity score") frame_score: float | None = Field(None, description="Frame-level similarity score") video_name: str | None = Field(None, description="Video name (sensor name for RTSP, filename for video_file)") class AttributeSearchResult(BaseModel): """Single attribute search result with URLs and metadata""" screenshot_url: str | None = Field(None, description="Screenshot URL") metadata: AttributeSearchMetadata = Field(..., description="Search result metadata") class AttributeSearchConfig(FunctionBaseConfig, name="attribute_search"): """Configuration for attribute search function""" rtvi_cv_endpoint: str = Field( ..., description="RTVI CV endpoint URL (e.g., http://localhost:9000)", ) es_endpoint: str = Field( ..., description="Elasticsearch endpoint URL", ) behavior_index: str = Field( default="mdx-behavior-2026-01-06", description="Elasticsearch index with object embeddings", ) frames_index: str | None = Field( default=None, description="Elasticsearch frames index for exact frame ID lookup (e.g., mdx-raw-2026-01-09)", ) enable_frame_lookup: bool = Field( default=True, description="Whether to perform frame-level lookup for more accurate bbox and frame_score. If False, only uses behavior-level embeddings.", ) vst_external_url: str = Field( ..., description="The external VST URL for client-facing URLs.", ) vst_internal_url: str | None = Field( default=None, description="The internal VST URL for validation requests. If not provided, uses vst_external_url.", ) # video_clip_tool: FunctionRef | None = Field( # default=None, # description="Optional reference to vst_video_clip tool for generating video URLs with overlays", # ) # NOTE: video_clip_tool removed - UI calls VST API directly for video overlays # NOTE: _generate_video_url removed - UI calls VST API directly for video overlays # async def _generate_video_url( # video_clip_fn: Any, # sensor: dict[str, Any], # object_ids: list[int], # start_time: str, # end_time: str | None, # vst_internal_url: str, # ) -> tuple[str | None, str | None]: # """Generate video URL with object overlays. Returns (video_url, stream_id).""" # try: # from vss_agents.tools.vst.timeline import get_timeline # from vss_agents.tools.vst.utils import get_stream_id # from vss_agents.tools.vst.video_clip import VSTVideoClipInput # # sensor_id_val = sensor.get("id", "") # logger.info(f"Video generation: sensor_id={sensor_id_val}") # stream_id = await get_stream_id(sensor_id_val, vst_internal_url) # logger.info(f"Video generation: resolved stream_id={stream_id}") # # timeline_start_str, _ = await get_timeline(stream_id, vst_internal_url) # logger.info(f"Video generation: timeline start={timeline_start_str}") # timeline_start_dt = datetime.fromisoformat(timeline_start_str.replace("Z", "+00:00")) # # start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) # end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00")) if end_time else start_dt # # # Add buffer for clip generation # clip_start_dt = start_dt - timedelta(seconds=1.5) # clip_end_dt = end_dt + timedelta(seconds=1.5) # # clip_start_offset = (clip_start_dt - timeline_start_dt).total_seconds() # clip_end_offset = (clip_end_dt - timeline_start_dt).total_seconds() # # # Generate video with or without overlays # video_input_dict = { # "sensor_id": sensor_id_val, # "start_time": clip_start_offset, # "end_time": clip_end_offset, # } # # if object_ids: # # Add overlay parameters when object IDs are provided # video_input_dict["object_ids"] = object_ids # video_input_dict["overlay_color"] = "green" # video_input_dict["overlay_thickness"] = 5 # logger.debug(f"Generating video with overlays for objects {object_ids}") # else: # logger.debug("Generating video without overlays") # # video_input = VSTVideoClipInput(**video_input_dict) # video_output = await video_clip_fn.ainvoke(video_input) # return video_output.video_url, video_output.stream_id # # except Exception as e: # logger.warning(f"Failed to generate video for objects {object_ids}: {e}") # return None, None async def _perform_frame_lookups( candidates: list[dict[str, Any]], query_embedding: list[float], es_client: AsyncElasticsearch, frames_index: str | list[str], timestamp_start: datetime | None, timestamp_end: datetime | None, ) -> list[tuple[int | None, dict | None, float | None, str | None] | None]: """ Perform frame-level lookups for all candidates to get more accurate bbox, timestamp, and frame_score. Returns a list of frame lookup results (or None) in the same order as candidates. Each result is a tuple: (frame_id, bbox, frame_score, timestamp) """ frame_lookup_tasks: list[Any] = [] # Use input timestamps directly - required for frame lookup if not timestamp_start or not timestamp_end: logger.warning("Frame lookup requires timestamp_start and timestamp_end - skipping frame lookups") return [None] * len(candidates) start_time = timestamp_start.isoformat().replace("+00:00", "Z") end_time = timestamp_end.isoformat().replace("+00:00", "Z") for candidate in candidates: source = candidate["_source"] sensor = source.get("sensor", {}) obj = source.get("object", {}) object_id = obj.get("id", "") sensor_id = sensor.get("id", "") if object_id and sensor_id: task = _get_frame_from_behavior( es_client=es_client, frames_index=frames_index, sensor_id=sensor_id, object_id=object_id, start_time=start_time, end_time=end_time, query_embedding=query_embedding, ) frame_lookup_tasks.append(task) else: frame_lookup_tasks.append(None) # Execute frame lookups if not frame_lookup_tasks: return [] tasks_to_run = [task if task is not None else asyncio.sleep(0) for task in frame_lookup_tasks] if any(task is not None for task in frame_lookup_tasks): logger.debug(f"Running {sum(1 for t in frame_lookup_tasks if t is not None)} frame lookups in parallel") frame_results = await asyncio.gather(*tasks_to_run, return_exceptions=True) # Filter out exceptions and convert to expected return type filtered_results: list[tuple[int | None, dict | None, float | None, str | None] | None] = [] for result in frame_results: if isinstance(result, Exception | BaseException): filtered_results.append(None) elif isinstance(result, tuple): filtered_results.append(result) else: filtered_results.append(None) return filtered_results async def _get_frame_from_behavior( es_client: AsyncElasticsearch, frames_index: str | list[str], sensor_id: str, object_id: str, start_time: str, end_time: str | None, query_embedding: list[float], ) -> tuple[int | None, dict | None, float | None, str | None]: """Find the best matching frame for an object using server-side cosine similarity.""" try: logger.debug( f"Frame search: sensor={sensor_id}, object={object_id}, time=[{start_time} to {end_time or start_time}]" ) # Convert list index to comma-separated string for Elasticsearch (handles exclusion patterns) search_frames_index_str = frames_index if isinstance(frames_index, str) else ",".join(frames_index) # Painless script: iterate through objects array, calculate cosine similarity for matching object painless_script = ( "double maxScore = -2.0; " "if (params._source.containsKey('objects')) { " " for (int i = 0; i < params._source.objects.size(); i++) { " " def obj = params._source.objects[i]; " " if (obj.id == params.target_id && obj.containsKey('embedding') && obj.embedding.containsKey('vector')) { " " def vec = obj.embedding.vector; " " double dotProduct = 0.0; " " double normA = 0.0; " " double normB = 0.0; " " for (int j = 0; j < Math.min(params.query_vector.size(), vec.size()); j++) { " " dotProduct += params.query_vector[j] * vec[j]; " " normA += params.query_vector[j] * params.query_vector[j]; " " normB += vec[j] * vec[j]; " " } " " if (normA > 0 && normB > 0) { " " double similarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); " " maxScore = Math.max(maxScore, similarity); " " } " " break; " " } " " } " "} " "return maxScore > -2.0 ? maxScore : 0.0;" ) search_query = { "query": { "function_score": { "query": { "bool": { "filter": [ {"term": {"sensorId.keyword": sensor_id}}, { "range": { "timestamp": ( {"gte": start_time, "lte": end_time} if end_time else {"gte": start_time} ) } }, ], "must": [ { "nested": { "path": "objects", "query": {"term": {"objects.id.keyword": object_id}}, } } ], } }, "script_score": { "script": { "source": painless_script, "params": { "query_vector": query_embedding, "target_id": object_id, }, } }, "boost_mode": "replace", } }, "size": 1, "_source": ["id", "timestamp", "sensorId", "objects"], } response = await es_client.search(index=search_frames_index_str, body=search_query) hits = response.get("hits", {}).get("hits", []) if not hits: logger.warning( f"No frame hits for object={object_id} on sensor={sensor_id} in [{start_time} to {end_time or start_time}]" ) return None, None, None, None best_hit = hits[0] frame_source = best_hit["_source"] raw_score = best_hit["_score"] # Normalize cosine similarity from [-1, 1] to [0, 1] best_score = (raw_score + 1.0) / 2.0 if raw_score > 0.0 else 0.0 best_frame_id = frame_source.get("id") best_timestamp = frame_source.get("timestamp", "") logger.debug( f"Frame found: id={best_frame_id}, raw_score={raw_score:.4f}, normalized={best_score:.4f}, ts={best_timestamp}" ) # Extract bbox from the matching object best_bbox = None for obj in frame_source.get("objects", []): if obj.get("id") == object_id: bbox_data = obj.get("bbox", {}) if bbox_data and bbox_data.get("leftX") is not None: best_bbox = { "leftX": bbox_data.get("leftX", 0), "rightX": bbox_data.get("rightX", 0), "topY": bbox_data.get("topY", 0), "bottomY": bbox_data.get("bottomY", 0), } break return best_frame_id, best_bbox, best_score, best_timestamp except Exception as e: logger.warning(f"Failed to find frame for object={object_id}: {e}", exc_info=True) return None, None, None, None async def _search_behavior( es_client: AsyncElasticsearch, index: str | list[str], query_embedding: list[float], top_k: int, min_similarity: float, timestamp_start: datetime | None = None, timestamp_end: datetime | None = None, video_sources: list[str] | None = None, ) -> list[dict[str, Any]]: """Search behavior embeddings and return candidates.""" # Build filters FIRST filter_clauses = [] if timestamp_start or timestamp_end: # Check for OVERLAP between behavior embedding time range and search time range # Behavior embedding overlaps if: behavior_start <= search_end AND behavior_end >= search_start # We need to find behavior embeddings where: # - behavior.timestamp (start) <= timestamp_end (behavior starts before/at search end) # - behavior.end >= timestamp_start (behavior ends after/at search start) # This ensures we catch behavior embeddings that overlap with the search window, even if # they start before or end after the window. overlap_filter: dict[str, Any] = {"bool": {"must": []}} if timestamp_start: # Behavior must end at or after search start (behavior.end >= timestamp_start) overlap_filter["bool"]["must"].append({"range": {"end": {"gte": timestamp_start.isoformat()}}}) if timestamp_end: # Behavior must start at or before search end (behavior.timestamp <= timestamp_end) overlap_filter["bool"]["must"].append({"range": {"timestamp": {"lte": timestamp_end.isoformat()}}}) # Only add filter if we have at least one condition if overlap_filter["bool"]["must"]: filter_clauses.append(overlap_filter) # Add video_sources filter if provided (same logic as embed_search) if video_sources: should_clauses = [] for vname in video_sources: escaped_vname = vname.replace("\\", "\\\\").replace("*", "\\*").replace("?", "\\?") # Check sensor.id (for RTSP streams and video files) should_clauses.append({"term": {"sensor.id.keyword": vname}}) should_clauses.append({"wildcard": {"sensor.id.keyword": f"*{escaped_vname}*"}}) # Check sensor.info.url (for uploaded video files) should_clauses.append({"wildcard": {"sensor.info.url.keyword": f"*{escaped_vname}"}}) should_clauses.append({"wildcard": {"sensor.info.url.keyword": f"*{escaped_vname}*"}}) # Check sensor.info.path (for RTSP streams - contains UUID) should_clauses.append({"wildcard": {"sensor.info.path.keyword": f"*{escaped_vname}*"}}) regex_escaped = re.escape(vname) should_clauses.append({"regexp": {"sensor.info.url": f".*{regex_escaped}"}}) should_clauses.append({"regexp": {"sensor.info.path": f".*{regex_escaped}"}}) filter_clauses.append( { "bool": { "should": should_clauses, "minimum_should_match": 1, } } ) # Build KNN query with filters INSIDE (so filters are applied during KNN search, not after) # Fetch more candidates to account for duplicates - we'll deduplicate and return top_k later # Use a multiplier to ensure we have enough unique results after deduplication # For top_k=1 (e.g., fusion reranking), fetch fewer candidates since we only need 1 result if top_k == 1: fetch_k = 10 # For fusion, we only need 1 result after deduplication else: # Increase overfetching for better diversity: 10x multiplier, minimum 200 candidates # This helps when many detections are of the same object (e.g., same person in different frames) fetch_k = max(top_k * 10, 200) # Fetch 10x top_k to account for duplicates and ensure diversity knn_query: dict[str, Any] = { "field": "embeddings.vector", "query_vector": query_embedding, "k": fetch_k, "num_candidates": max(fetch_k * 2, 100), # HNSW exploration pool } # Add filter to KNN query if present (Elasticsearch will filter DURING vector search) # When multiple filters, combine them in a bool.must query if filter_clauses: if len(filter_clauses) > 1: knn_query["filter"] = {"bool": {"must": filter_clauses}} else: knn_query["filter"] = filter_clauses[0] logger.debug(f"Query embedding: dim={len(query_embedding)}") logger.info( f"KNN search: top_k={top_k}, fetch_k={fetch_k}, k={knn_query['k']}, num_candidates={knn_query['num_candidates']}, filters={len(filter_clauses)}" ) # Construct search query # Fetch more results initially to account for duplicates (will deduplicate and return top_k later) search_query: dict[str, Any] = { "knn": knn_query, "size": fetch_k, # Fetch more to account for duplicates "min_score": min_similarity, "_source": [ "object.id", "object.type", "object.bbox", "sensor.id", "sensor.stream_id", "timestamp", "end", ], } logger.info(f"Searching objects: top_k={top_k}, fetching {fetch_k} candidates to account for duplicates") # Convert list index to comma-separated string for Elasticsearch (handles exclusion patterns) search_index_str = index if isinstance(index, str) else ",".join(index) logger.debug(f"Searching index: {search_index_str}") try: response = await es_client.search(index=search_index_str, body=search_query) except ESNotFoundError as e: # Index doesn't exist - return empty result with informative error logger.error(f"Elasticsearch index '{index}' not found: {e}") raise ValueError( f"Search index '{index}' does not exist. Please ensure videos have been ingested before searching." ) from e # Log ES response total_hits = response["hits"]["total"]["value"] raw_hits = len(response["hits"]["hits"]) logger.info(f"Found {raw_hits} candidates (total: {total_hits})") if raw_hits > 1: # ES returns results sorted by score descending (highest score first) # Only log score range when there are multiple results top_score = response["hits"]["hits"][0]["_score"] bottom_score = response["hits"]["hits"][-1]["_score"] logger.debug(f"Score range: {top_score:.4f} (best) to {bottom_score:.4f} (worst)") # Collect candidates (already sorted by score descending from ES) candidates = list(response["hits"]["hits"]) logger.debug(f"Collected {len(candidates)} candidates for processing") return candidates async def _build_result( hit: dict[str, Any], frame_result: Any, input_timestamp_start: datetime | None = None, input_timestamp_end: datetime | None = None, ) -> AttributeSearchResult: """ Build an AttributeSearchResult from a behavior hit and frame lookup result. If input_timestamp_start and input_timestamp_end are provided, they will be used for the output start_time and end_time. Otherwise, behavior embedding timestamps are used. """ score = hit["_score"] source = hit["_source"] obj = source.get("object", {}) sensor = source.get("sensor", {}) object_id = obj.get("id", "unknown") sensor_id = sensor.get("id", "unknown") logger.debug(f"Processing: sensor={sensor_id}, object={object_id}, score={score:.4f}") # Extract frame lookup results frame_bbox = None query_to_frame_score = None best_frame_timestamp = None if frame_result is not None and not isinstance(frame_result, Exception): _, frame_bbox, query_to_frame_score, best_frame_timestamp = frame_result if best_frame_timestamp: logger.debug(f"Frame score={query_to_frame_score:.4f}") elif isinstance(frame_result, Exception): logger.debug(f"Frame lookup failed for object {object_id}: {frame_result}") # Use frame bbox if available, otherwise fall back to behavior bbox # Clean up bbox to only include relevant fields (remove embeddings, info, etc.) if frame_bbox is not None: final_bbox = frame_bbox else: behavior_bbox = obj.get("bbox", {}) # Extract only relevant bbox fields, excluding embeddings, info, and confidence final_bbox = ( { "leftX": behavior_bbox.get("leftX"), "rightX": behavior_bbox.get("rightX"), "topY": behavior_bbox.get("topY"), "bottomY": behavior_bbox.get("bottomY"), } if behavior_bbox else None ) # Extract behavior embedding timestamps (start and end) # Convert empty strings to None for proper type handling behavior_end_raw = source.get("end", "") behavior_start_raw = source.get("timestamp", "") behavior_end = cast("str | None", behavior_end_raw if behavior_end_raw else None) behavior_start = cast("str | None", behavior_start_raw if behavior_start_raw else None) # Use best frame timestamp if available, otherwise fall back to behavior timestamp # For behavior data, use midpoint between start and end timestamps if best_frame_timestamp: final_timestamp = best_frame_timestamp else: if behavior_start and behavior_end: # Calculate midpoint between start and end start_dt = datetime.fromisoformat(behavior_start.replace("Z", "+00:00")) end_dt = datetime.fromisoformat(behavior_end.replace("Z", "+00:00")) midpoint_dt = start_dt + (end_dt - start_dt) / 2 final_timestamp = midpoint_dt.isoformat().replace("+00:00", "Z") else: final_timestamp = behavior_end if behavior_end else behavior_start # Log scores if query_to_frame_score is not None: logger.debug(f"Object {object_id}: behavior_score={score:.4f}, frame_score={query_to_frame_score:.4f}") else: logger.debug(f"Object {object_id}: behavior_score={score:.4f} (no frame score)") # Determine start_time and end_time for output: # - If input timestamps were provided to attribute search, use those # - Otherwise, use behavior embedding timestamps # - If behavior_end is missing, use behavior_start for both output_start_time: str | None output_end_time: str | None if input_timestamp_start is not None: # Convert datetime to ISO string from vss_agents.utils.time_convert import datetime_to_iso8601 output_start_time = datetime_to_iso8601(input_timestamp_start) output_end_time = ( datetime_to_iso8601(input_timestamp_end) if input_timestamp_end is not None else output_start_time ) logger.debug(f"Object {object_id}: Using input timestamps: start={output_start_time}, end={output_end_time}") else: # Use behavior embedding timestamps output_start_time = behavior_start if behavior_start else None # If end is missing, use start for both (single timestamp case) output_end_time = behavior_end if behavior_end else (behavior_start if behavior_start else None) logger.debug( f"Object {object_id}: Using behavior embedding timestamps: start={output_start_time}, end={output_end_time}" ) # Build metadata metadata = AttributeSearchMetadata( sensor_id=sensor_id, object_id=object_id, object_type=obj.get("type", "unknown"), frame_timestamp=final_timestamp, start_time=output_start_time, end_time=output_end_time, bbox=final_bbox, behavior_score=score, frame_score=query_to_frame_score, video_name=None, # Will be set later when converting sensor_id to UUID ) return AttributeSearchResult( screenshot_url=None, # Will be set later in search_attributes metadata=metadata, ) async def _extend_clip_to_one_second( result: AttributeSearchResult, vst_internal_url: str | None, vst_external_url: str, ) -> None: """ Extend clip duration to at least MIN_CLIP_DURATION_SECONDS while respecting VST timeline bounds. If the clip duration is < MIN_CLIP_DURATION_SECONDS, extends it to MIN_CLIP_DURATION_SECONDS centered on the midpoint, clipped to VST timeline bounds. Modifies the result's start_time and end_time in place. Args: result: AttributeSearchResult to extend vst_internal_url: Internal VST URL for timeline lookups vst_external_url: External VST URL (fallback for resolution) """ if not result.metadata or not result.metadata.start_time or not result.metadata.end_time: return if not result.metadata.sensor_id: return try: from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import get_stream_id from vss_agents.utils.time_convert import datetime_to_iso8601 from vss_agents.utils.time_convert import iso8601_to_datetime start_dt = iso8601_to_datetime(result.metadata.start_time) end_dt = iso8601_to_datetime(result.metadata.end_time) duration = (end_dt - start_dt).total_seconds() if duration >= MIN_CLIP_DURATION_SECONDS: return # Already >= minimum duration, no extension needed # Get stream_id from sensor_id (may be sensor name or UUID) vst_internal_for_resolution = vst_internal_url if vst_internal_url else vst_external_url stream_id = await get_stream_id(result.metadata.sensor_id, vst_internal_for_resolution) if not stream_id: logger.warning(f"Could not resolve stream_id for sensor_id={result.metadata.sensor_id}") return # Get VST timeline bounds timeline_start_iso, timeline_end_iso = await get_timeline(stream_id, vst_internal_for_resolution) timeline_start = iso8601_to_datetime(timeline_start_iso) timeline_end = iso8601_to_datetime(timeline_end_iso) # Calculate midpoint of current range midpoint = start_dt + (end_dt - start_dt) / 2 # Extend to minimum duration centered on midpoint half_duration = MIN_CLIP_DURATION_SECONDS / 2.0 new_start = midpoint - timedelta(seconds=half_duration) new_end = midpoint + timedelta(seconds=half_duration) # Clip to VST timeline bounds new_start = max(new_start, timeline_start) new_end = min(new_end, timeline_end) # Ensure we still have at least minimum duration if possible if (new_end - new_start).total_seconds() < MIN_CLIP_DURATION_SECONDS: # Try to extend from the end if there's room if new_end < timeline_end: new_end = min(new_start + timedelta(seconds=MIN_CLIP_DURATION_SECONDS), timeline_end) # Or extend from the start if there's room elif new_start > timeline_start: new_start = max(new_end - timedelta(seconds=MIN_CLIP_DURATION_SECONDS), timeline_start) result.metadata.start_time = datetime_to_iso8601(new_start) result.metadata.end_time = datetime_to_iso8601(new_end) logger.info( f"Extended clip < {MIN_CLIP_DURATION_SECONDS}s to {MIN_CLIP_DURATION_SECONDS}s: {result.metadata.sensor_id} " f"({duration:.3f}s -> {(new_end - new_start).total_seconds():.3f}s)" ) except Exception as e: logger.warning( f"Failed to extend clip for {result.metadata.sensor_id if result.metadata else 'unknown'}: {e}. " f"Using original timestamps." ) def _deduplicate_by_object( results: list[AttributeSearchResult], candidates: list[dict[str, Any]] | None = None, ) -> list[AttributeSearchResult]: """ Merge duplicate results for the same (sensor_id, object_id) pair. Keep the first occurrence (highest score) and merge time ranges by updating start_time and end_time. Args: results: List of AttributeSearchResult (already sorted by similarity descending) candidates: Optional list of original ES hits (must match results by index) Returns: Deduplicated list of AttributeSearchResult (maintains sort order) """ merged: dict[tuple[str, str], tuple[AttributeSearchResult, int]] = {} duplicate_count = 0 merge_count = 0 for idx, result in enumerate(results): if not result.metadata: continue key = (result.metadata.sensor_id, result.metadata.object_id) if key not in merged: merged[key] = (result, idx) else: # Merge: update start_time and end_time of existing result with earliest start and latest end existing_result, existing_idx = merged[key] duplicate_count += 1 logger.debug( f"Deduplication: Found duplicate for sensor_id={result.metadata.sensor_id}, " f"object_id={result.metadata.object_id}, score={result.metadata.behavior_score:.4f}. " f"Existing score={existing_result.metadata.behavior_score:.4f}." ) if candidates and existing_idx < len(candidates) and idx < len(candidates): existing_source = candidates[existing_idx].get("_source", {}) new_source = candidates[idx].get("_source", {}) # Use metadata's start_time/end_time (which may have already been merged) as the baseline # This ensures we accumulate the merged time range across multiple duplicates existing_start = existing_result.metadata.start_time or existing_source.get("timestamp") existing_end = existing_result.metadata.end_time or existing_source.get("end") new_start = new_source.get("timestamp") new_end = new_source.get("end") logger.debug( f"Deduplication: Existing time range: [{existing_start} to {existing_end}], " f"New time range: [{new_start} to {new_end}]" ) # Find earliest start and latest end earliest_start = existing_start latest_end = existing_end if new_start and existing_start: try: if datetime.fromisoformat(new_start.replace("Z", "+00:00")) < datetime.fromisoformat( existing_start.replace("Z", "+00:00") ): earliest_start = new_start logger.debug(f"Deduplication: Updated earliest_start to {earliest_start}") except (ValueError, AttributeError): pass elif new_start: earliest_start = new_start if new_end and existing_end: try: if datetime.fromisoformat(new_end.replace("Z", "+00:00")) > datetime.fromisoformat( existing_end.replace("Z", "+00:00") ): latest_end = new_end logger.debug(f"Deduplication: Updated latest_end to {latest_end}") except (ValueError, AttributeError): pass elif new_end: latest_end = new_end # Update result's start_time and end_time directly if earliest_start != existing_start or latest_end != existing_end: merge_count += 1 logger.info( f"Deduplication: Merging time ranges for sensor_id={result.metadata.sensor_id}, " f"object_id={result.metadata.object_id}. " f"Merged range: start_time={earliest_start}, end_time={latest_end}" ) # Update the existing result's start_time and end_time directly existing_result.metadata.start_time = earliest_start existing_result.metadata.end_time = latest_end else: logger.debug( f"Deduplication: Cannot merge timestamps (candidates not available) for " f"sensor_id={result.metadata.sensor_id}, object_id={result.metadata.object_id}" ) if duplicate_count > 0: logger.info( f"Deduplication: Found {duplicate_count} duplicate(s), merged {merge_count} time range(s). " f"Kept {len(merged)} unique result(s) from {len(results)} total result(s)." ) return [result for result, _ in merged.values()] async def search_by_attributes( query_embedding: list[float], es_client: AsyncElasticsearch, index: str | list[str], timestamp_start: datetime | None = None, timestamp_end: datetime | None = None, video_sources: list[str] | None = None, top_k: int = 1, min_similarity: float = 0.7, frames_index: str | list[str] | None = None, enable_frame_lookup: bool = True, exclude_videos: list[dict[str, str]] | None = None, ) -> list[AttributeSearchResult]: """Search for objects by attribute embeddings and return scores per object-video pair.""" exclude_videos = exclude_videos or [] try: # Phase 1: Search behavior embeddings candidates = await _search_behavior( es_client=es_client, index=index, query_embedding=query_embedding, top_k=top_k, min_similarity=min_similarity, timestamp_start=timestamp_start, timestamp_end=timestamp_end, video_sources=video_sources, ) # Phase 2: Perform frame lookups (if enabled) to get more accurate bbox, timestamp, and frame_score # When disabled, we use behavior embedding data directly (bbox, timestamp from behavior) if candidates: if len(candidates) > 1: scores = [c["_score"] for c in candidates] logger.info( f"Processing {len(candidates)} candidate(s). Score range: {max(scores):.4f} to {min(scores):.4f}" ) else: logger.info(f"Processing {len(candidates)} candidate(s).") else: logger.info(f"No candidates passed min_similarity threshold ({min_similarity})") # Phase 3: Build results results = [] if enable_frame_lookup and frames_index: # Perform frame lookups to get more accurate bbox, timestamp, and frame_score frame_results = await _perform_frame_lookups( candidates=candidates, query_embedding=query_embedding, es_client=es_client, frames_index=frames_index, timestamp_start=timestamp_start, timestamp_end=timestamp_end, ) # Build results with frame lookup data for idx, hit in enumerate(candidates): frame_result = frame_results[idx] if idx < len(frame_results) else None result = await _build_result( hit=hit, frame_result=frame_result, ) results.append(result) else: # Frame lookup disabled - use behavior-level data only (bbox, timestamp from behavior embeddings) if not enable_frame_lookup: logger.debug( "Frame lookup disabled - using behavior-level embeddings only (bbox, timestamp from behavior data)" ) # Build results using behavior data directly for hit in candidates: result = await _build_result( hit=hit, frame_result=None, # No frame lookup, use behavior data ) results.append(result) logger.info(f"Matched {len(results)} object-video pairs") # Deduplicate: Keep only the best result per (sensor_id, object_id) pair, merge timestamps results = _deduplicate_by_object(results, candidates) logger.info(f"After deduplication: {len(results)} unique object-video pairs") # Remove excluded videos before top_k truncation # TODO: make this more efficient filtered_results = deepcopy(results) for result in results: for exclude_video in exclude_videos: if ( result.metadata.sensor_id == exclude_video.get("sensor_id", "") and result.metadata.start_time == exclude_video.get("start_timestamp", "") and result.metadata.end_time == exclude_video.get("end_timestamp", "") ): filtered_results.remove(result) break results = filtered_results # Return top_k after deduplication if top_k > 0 and len(filtered_results) > top_k: filtered_results = filtered_results[:top_k] logger.info(f"Returning top {top_k} results after deduplication") return filtered_results except Exception as e: logger.error(f"Attribute search failed: {e}", exc_info=True) return [] async def search_single_attribute( query_text: str, search_input: AttributeSearchInput, embed_client: EmbedClient, es_client: AsyncElasticsearch, index: str | list[str], frames_index: str | list[str] | None, enable_frame_lookup: bool = True, ) -> list[AttributeSearchResult]: """Search for a single attribute.""" query_embedding = await embed_client.get_text_embedding(query_text) return await search_by_attributes( query_embedding=query_embedding, es_client=es_client, index=index, timestamp_start=search_input.timestamp_start, timestamp_end=search_input.timestamp_end, video_sources=search_input.video_sources, top_k=search_input.top_k, min_similarity=search_input.min_similarity, frames_index=frames_index, enable_frame_lookup=enable_frame_lookup, exclude_videos=search_input.exclude_videos, ) async def search_attributes( search_input: AttributeSearchInput, embed_client: EmbedClient, es_client: AsyncElasticsearch, index: str, vst_external_url: str, vst_internal_url: str | None = None, frames_index: str | None = None, enable_frame_lookup: bool = True, ) -> list[AttributeSearchResult]: """ Search for objects by visual attributes. Two modes: - fuse_multi_attribute=True (default): Fuses multiple attributes (combines object IDs for single screenshot) - fuse_multi_attribute=False: Appends top_k results per attribute independently (no fusion) """ queries = [search_input.query] if isinstance(search_input.query, str) else search_input.query logger.info(f"Searching {len(queries)} attribute(s) (fuse_multi_attribute={search_input.fuse_multi_attribute})") # Choose index(es) by source_type: video_file -> behavior_index; otherwise mdx-behavior-* excluding behavior_index source_type = search_input.source_type search_index: str | list[str] search_frames_index: str | list[str] | None if source_type == "video_file": search_index = index search_frames_index = frames_index else: # For rtsp/stream sources, search all mdx-behavior-* indexes except the video_file one search_index = ["mdx-behavior-*", "-" + index] # For frames, search all mdx-raw-* indexes except the video_file one (if frames_index is set) if frames_index: search_frames_index = ["mdx-raw-*", "-" + frames_index] else: search_frames_index = "mdx-raw-*" logger.info(f"Search index(es): {search_index} (source_type={source_type})") if search_frames_index: logger.info(f"Frames index(es): {search_frames_index} (source_type={source_type})") if search_input.fuse_multi_attribute: # FUSE MODE: Current behavior - fuse object IDs for single screenshot return await _fuse_multi_attribute( queries=queries, search_input=search_input, embed_client=embed_client, es_client=es_client, search_index=search_index, search_frames_index=search_frames_index, enable_frame_lookup=enable_frame_lookup, vst_external_url=vst_external_url, vst_internal_url=vst_internal_url, ) else: # APPEND MODE: Return top_k per attribute independently (no fusion) return await _append_multi_attribute( queries=queries, search_input=search_input, embed_client=embed_client, es_client=es_client, search_index=search_index, search_frames_index=search_frames_index, enable_frame_lookup=enable_frame_lookup, vst_external_url=vst_external_url, vst_internal_url=vst_internal_url, ) async def _fuse_multi_attribute( queries: list[str], search_input: AttributeSearchInput, embed_client: EmbedClient, es_client: AsyncElasticsearch, search_index: str | list[str], search_frames_index: str | list[str] | None, enable_frame_lookup: bool, vst_external_url: str, vst_internal_url: str | None, ) -> list[AttributeSearchResult]: """Fuse mode: Combine object IDs from all attributes for single screenshot.""" # Search all attributes with top_k=1 search_input_single = AttributeSearchInput( query=search_input.query, source_type=search_input.source_type, timestamp_start=search_input.timestamp_start, timestamp_end=search_input.timestamp_end, video_sources=search_input.video_sources, top_k=1, min_similarity=search_input.min_similarity, fuse_multi_attribute=True, # Preserve flag exclude_videos=search_input.exclude_videos, ) tasks = [ search_single_attribute( query_text=q, search_input=search_input_single, embed_client=embed_client, es_client=es_client, index=search_index, frames_index=search_frames_index, enable_frame_lookup=enable_frame_lookup, ) for q in queries ] results_list = await asyncio.gather(*tasks) all_results = [result for results in results_list for result in results] logger.info(f"Found {len(all_results)} results from {len(queries)} attribute(s)") # Collect object IDs and sensor info from results object_ids = [] sensor_id = None frame_timestamps = [] for result in all_results: if result.metadata: try: object_ids.append(int(result.metadata.object_id)) # Extract sensor_id from the first result (all should have the same sensor.id due to filtering) if sensor_id is None: sensor_id = result.metadata.sensor_id if result.metadata.frame_timestamp: frame_timestamps.append(result.metadata.frame_timestamp) except (ValueError, TypeError): pass # Generate screenshot (no video generation) - single screenshot for all fused objects if sensor_id and vst_external_url and search_input.timestamp_start and search_input.timestamp_end: try: from vss_agents.tools.vst.utils import get_stream_id start_time = search_input.timestamp_start.isoformat().replace("+00:00", "Z") # Get stream_id from sensor_id (accepts either camera name or UUID) # Use internal URL for stream resolution (agent needs internal access) vst_internal_for_resolution = vst_internal_url if vst_internal_url else vst_external_url stream_id = await get_stream_id(sensor_id, vst_internal_for_resolution) screenshot_url = None if stream_id: # Use midpoint of the time range for screenshot (most likely to show all objects) screenshot_timestamp = start_time if frame_timestamps: # Sort timestamps and pick the middle one (median) sorted_timestamps = sorted(frame_timestamps) mid_idx = len(sorted_timestamps) // 2 screenshot_timestamp = sorted_timestamps[mid_idx] logger.debug(f"Using median frame timestamp for screenshot: {screenshot_timestamp}") screenshot_url = build_screenshot_url(vst_external_url, stream_id, screenshot_timestamp) # Update all results with screenshot and convert sensor_id to stream_id (UUID) if stream_id: for result in all_results: if screenshot_url and not result.screenshot_url: result.screenshot_url = screenshot_url # Update metadata.sensor_id to stream_id (UUID) if result.metadata: result.metadata.sensor_id = stream_id logger.debug(f"Updated sensor_id to stream_id '{stream_id}' for fused results") logger.info(f"Generated screenshot for {len(object_ids)} objects at stream {stream_id}") except Exception as e: logger.warning(f"Failed to generate screenshot: {e}", exc_info=True) return all_results async def _append_multi_attribute( queries: list[str], search_input: AttributeSearchInput, embed_client: EmbedClient, es_client: AsyncElasticsearch, search_index: str | list[str], search_frames_index: str | list[str] | None, enable_frame_lookup: bool, vst_external_url: str, vst_internal_url: str | None, ) -> list[AttributeSearchResult]: """Append mode: Return top_k results per attribute independently (no fusion).""" # Search each attribute with top_k (not top_k=1) search_input_per_attr = AttributeSearchInput( query=search_input.query, source_type=search_input.source_type, timestamp_start=search_input.timestamp_start, timestamp_end=search_input.timestamp_end, video_sources=search_input.video_sources, top_k=search_input.top_k, # Use the requested top_k per attribute min_similarity=search_input.min_similarity, fuse_multi_attribute=False, # Preserve flag exclude_videos=search_input.exclude_videos, ) # Search each attribute independently all_results = [] for attr_query in queries: try: attr_results = await search_single_attribute( query_text=attr_query, search_input=search_input_per_attr, embed_client=embed_client, es_client=es_client, index=search_index, frames_index=search_frames_index, enable_frame_lookup=enable_frame_lookup, ) # Extend clips < 1 second to 1 second while respecting VST bounds if attr_results and vst_internal_url: for result in attr_results: await _extend_clip_to_one_second(result, vst_internal_url, vst_external_url) # Generate screenshot for each attribute's results independently if attr_results and vst_external_url: for result in attr_results: if result.metadata and result.metadata.sensor_id and result.metadata.frame_timestamp: try: from vss_agents.tools.vst.utils import get_stream_id # Set video_name to original sensor_id (sensor name) before converting to UUID result.metadata.video_name = result.metadata.sensor_id vst_internal_for_resolution = vst_internal_url if vst_internal_url else vst_external_url stream_id = await get_stream_id(result.metadata.sensor_id, vst_internal_for_resolution) # Update metadata.sensor_id to stream_id (UUID) if stream_id: result.metadata.sensor_id = stream_id if stream_id and not result.screenshot_url: result.screenshot_url = build_screenshot_url( vst_external_url, stream_id, result.metadata.frame_timestamp ) except Exception as e: logger.debug(f"Failed to generate screenshot for attribute '{attr_query}': {e}") all_results.extend(attr_results) logger.info(f"Attribute '{attr_query}': found {len(attr_results)} results") except Exception as e: logger.warning(f"Attribute search failed for '{attr_query}': {e}") continue logger.info(f"Append mode: found {len(all_results)} total results from {len(queries)} attribute(s)") # Deduplicate: Keep only the best result per (sensor_id, object_id) pair all_results = _deduplicate_by_object(all_results) logger.info(f"After deduplication: {len(all_results)} unique object-video pairs") # Return top_k after deduplication top_k = search_input.top_k if top_k > 0 and len(all_results) > top_k: all_results = all_results[:top_k] logger.info(f"Returning top {top_k} results after deduplication") return all_results @register_function(config_type=AttributeSearchConfig) async def build_attribute_search(config: AttributeSearchConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: """NAT function builder for attribute search.""" # Always use RTVI CV for text embeddings embed_client: EmbedClient = RTVICVEmbedClient(config.rtvi_cv_endpoint) logger.info("Text embedding: rtvi_cv") # Create Elasticsearch client with increased timeout for nested frame queries es_client = AsyncElasticsearch( hosts=[config.es_endpoint], request_timeout=30, # Increase from default 10s to 30s for nested queries max_retries=0, # Don't retry on timeout ) async def attribute_search_fn(search_input: AttributeSearchInput) -> list[AttributeSearchResult]: return await search_attributes( search_input, embed_client, es_client, config.behavior_index, config.vst_external_url, config.vst_internal_url, config.frames_index, config.enable_frame_lookup, ) yield FunctionInfo.create( single_fn=attribute_search_fn, description="Search for objects by visual attributes", input_schema=AttributeSearchInput, # Note: single_output_schema removed to avoid Python 3.13 isinstance() issues with parameterized generics ) ================================================ FILE: agent/src/vss_agents/tools/chart_generator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator from enum import StrEnum import io import logging import os from pathlib import Path from typing import Annotated from urllib.parse import urlparse from urllib.parse import urlunparse import matplotlib import matplotlib.pyplot as plt from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.api_server import ChatRequest from nat.data_models.api_server import ChatResponse from nat.data_models.component_ref import ObjectStoreRef from nat.data_models.function import FunctionBaseConfig from nat.object_store.models import ObjectStoreItem from pydantic import AnyUrl from pydantic import BaseModel from pydantic import Field from pydantic import HttpUrl from pydantic import UrlConstraints from pydantic import field_validator logger = logging.getLogger(__name__) class ChartType(StrEnum): BAR = "bar" PIE = "pie" class ChartFileFormat(StrEnum): PNG = "png" SVG = "svg" JPEG = "jpeg" class ChartData(BaseModel): chart_file_format: ChartFileFormat = ChartFileFormat.PNG title: str = "" class BarChartData(ChartData): x_categories: list[str] series: dict[str, list[float]] x_label: str = "" y_label: str = "" class PieChartData(ChartData): sizes: list[float] labels: list[str] S3Url = Annotated[AnyUrl, UrlConstraints(allowed_schemes=["s3"])] class ChartGeneratorConfig(FunctionBaseConfig, name="chart_generator"): object_store_name: ObjectStoreRef | None = Field( default=None, description="The object store to store generated images." ) object_store_base_url: HttpUrl | S3Url = Field( default=HttpUrl("http://localhost:8000/static/"), description="The base URL of the object store for serving files via HTTP.", ) @field_validator("object_store_base_url", mode="before") @classmethod def must_be_directory_url(cls, v: str) -> str: parsed = urlparse(v) if parsed.query or parsed.fragment: raise ValueError("URL must not contain query or fragment") normalized_path = parsed.path.rstrip("/") last_segment = os.path.basename(normalized_path) if "." in last_segment: raise ValueError("URL must point to a directory, not a file") final_path = normalized_path + "/" new_url = urlunparse(parsed._replace(path=final_path)) return new_url class ChartGeneratorInput(BaseModel): """Input for the chart generation tool""" charts_data: list[BarChartData | PieChartData] output_dir: str | None = None file_prefix: str = "chart_" @field_validator("output_dir", mode="before") @classmethod def validate_and_sanitize_output_dir(cls, v: str | None) -> str | None: if v is None: return None # We return absolute path without first / to avoid double // in the URL return str(Path("/" + v).resolve())[1:] class ChartGenExecOutput(BaseModel): success: bool error_message: str | None object_store_key: str | None = Field( default=None, description="Object store key for the generated chart.", ) def plot_bar_chart(bar_chart_data: BarChartData) -> matplotlib.figure.Figure: """ Generates a grouped bar chart and returns the figure & axes. Parameters: - x_categories: list[str] | Categories for the x-axis - series: dict[str, list[float]] | Dict of series to plot, e.g. {"value1": [10, 20], "value2": [15, 25]} - title: str | Title of the chart - xlabel: str | Label for the x-axis - ylabel: str | Label for the y-axis Returns: - fig, ax: matplotlib Figure and Axes objects """ x_categories = bar_chart_data.x_categories series = bar_chart_data.series title = bar_chart_data.title x_label = bar_chart_data.x_label y_label = bar_chart_data.y_label fig, ax = plt.subplots() n_series = len(series) x_positions = range(len(x_categories)) bar_width = 0.8 / n_series for i, (label, y_values) in enumerate(series.items()): ax.bar([pos + i * bar_width for pos in x_positions], y_values, width=bar_width, label=label) ax.set_xticks([pos + bar_width * (n_series - 1) / 2 for pos in x_positions]) ax.set_xticklabels(x_categories, rotation=45, ha="right") ax.set_title(title) ax.set_xlabel(x_label) ax.set_ylabel(y_label) ax.legend() fig.tight_layout() return fig def plot_pie_chart(pie_chart_data: PieChartData) -> matplotlib.figure.Figure: """ Plot a pie chart using Matplotlib. Parameters: - pie_chart_data: PieChartData Example: ``` plot_pie_chart( PieChartData(sizes=[30, 20, 50], labels=["A", "B", "C"], title="Pie Chart"), ) ``` """ sizes = pie_chart_data.sizes labels = pie_chart_data.labels title = pie_chart_data.title fig, ax = plt.subplots() wedges, *_ = ax.pie( sizes, labels=labels, ) if title: ax.set_title(title) if labels is not None: ax.legend(wedges, labels, loc="best") plt.tight_layout() return fig def convert_to_format(chart: matplotlib.figure.Figure, chart_file_format: ChartFileFormat) -> bytes: buf = io.BytesIO() chart.savefig(buf, format=chart_file_format.value) buf.seek(0) return buf.getvalue() def _str_input_converter(input: str) -> ChartGeneratorInput: return ChartGeneratorInput.model_validate_json(input) def _chat_request_input_converter(request: ChatRequest) -> ChartGeneratorInput: try: return ChartGeneratorInput.model_validate_json(request.messages[-1].content) except Exception: logger.exception("Error in chat request input converter.") raise @register_function(config_type=ChartGeneratorConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def chart_generator(config: ChartGeneratorConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: if config.object_store_name: object_store = await builder.get_object_store_client(object_store_name=config.object_store_name) else: object_store = None def _output_converter(output: list[ChartGenExecOutput]) -> str: output_str = "" for chart in output: if chart.success and chart.object_store_key: output_str += f'Image' return output_str def _chat_response_output_converter(response: list[ChartGenExecOutput]) -> ChatResponse: return ChatResponse.from_string(_output_converter(response)) async def generate_chart(chart_generator_input: ChartGeneratorInput) -> list[ChartGenExecOutput]: exec_outputs = [] for i, chart_data in enumerate(chart_generator_input.charts_data): success = False error_message = None try: match chart_data: case BarChartData(): chart = plot_bar_chart(chart_data) case PieChartData(): chart = plot_pie_chart(chart_data) case other: raise RuntimeError(f"Unsupported chart data type: {other}") chart_bytes = convert_to_format(chart, chart_data.chart_file_format) key = None success = True if object_store and chart_generator_input.output_dir: output_dir = chart_generator_input.output_dir item = ObjectStoreItem(data=chart_bytes, content_type=f"image/{chart_data.chart_file_format.value}") key = f"{output_dir}/{chart_generator_input.file_prefix}{i}.{chart_data.chart_file_format.value}" await object_store.upsert_object(key, item) success = True else: raise ValueError("object_store and output_dir must be provided for chart generation") except Exception as e: raise RuntimeError("Failed to generate chart.") from e exec_outputs.append( ChartGenExecOutput( success=success, error_message=error_message, object_store_key=key, ) ) return exec_outputs try: yield FunctionInfo.create( single_fn=generate_chart, description="Generate chart", input_schema=ChartGeneratorInput, single_output_schema=list[ChartGenExecOutput], converters=[ _str_input_converter, _chat_request_input_converter, _output_converter, _chat_response_output_converter, ], ) except Exception: logger.error("Error in chart generator, exit early") raise ================================================ FILE: agent/src/vss_agents/tools/code_executor/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from .docker_backend import DockerExecutor ================================================ FILE: agent/src/vss_agents/tools/code_executor/docker_backend/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Docker backend for code execution. The ImageBuilder is a singleton that manages Docker images across all tools. Images are automatically cleaned up when the process exits via atexit handler. Manual cleanup should only be done in special cases (e.g., testing) as it affects all tools. """ from .docker_executor import DockerExecutor from .image_builder import ImageBuilder def cleanup_docker_resources() -> None: """ Manually cleanup all Docker resources managed by the ImageBuilder singleton. WARNING: This affects ALL tools using the ImageBuilder singleton. Only call this when you're sure no other tools are using Docker images. In normal operation, cleanup happens automatically on process exit. """ ImageBuilder.reset_instance() __all__ = ["DockerExecutor", "ImageBuilder", "cleanup_docker_resources"] ================================================ FILE: agent/src/vss_agents/tools/code_executor/docker_backend/docker_executor.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import builtins import contextlib from datetime import datetime import io import logging import os import tarfile import time import docker from vss_agents.tools.code_executor.docker_backend.image_builder import ImageBuilder logger = logging.getLogger(__name__) class DockerExecutor: def __init__(self, gpu: bool = False): self.client = docker.from_env() self.gpu = gpu self.builder = ImageBuilder() def _pack_files(self, files: dict[str, str | bytes]) -> bytes: data = io.BytesIO() with tarfile.open(fileobj=data, mode="w") as tar: created_dirs = set() for path, content in files.items(): # Normalize path path = path.lstrip("/") # Create parent directories dir_path = os.path.dirname(path) if dir_path and dir_path not in created_dirs: parts = dir_path.split("/") for i in range(1, len(parts) + 1): parent = "/".join(parts[:i]) if parent not in created_dirs: dir_info = tarfile.TarInfo(name=parent) dir_info.type = tarfile.DIRTYPE dir_info.mode = 0o755 dir_info.mtime = int(time.time()) tar.addfile(dir_info) created_dirs.add(parent) # Handle both string and bytes content if isinstance(content, str): file_data = content.encode("utf-8") # Make scripts executable if they look like scripts mode = 0o755 if content.startswith("#!") else 0o644 else: file_data = content mode = 0o644 # Add the file info = tarfile.TarInfo(name=path) info.size = len(file_data) info.mtime = int(time.time()) info.mode = mode info.uid = 1000 info.gid = 1000 tar.addfile(info, io.BytesIO(file_data)) data.seek(0) return data.getvalue() def run_code( self, code: str, files: dict[str, str] | None = None, image: str = "python", cmd: list[str] | None = None, debug: bool = False, timeout_sec: int = 10, cpu_limit: float = 1.0, # 1 vCPU mem_limit: str = "1g", network: bool = False, ) -> dict[str, str | int]: image_tag = self.builder.get_image_tag(image) workdir = f"/job-{datetime.now().strftime('%Y%m%d%H%M%S')}" # Default command to run python code if cmd is None: if debug: cmd = [ "bash", "-c", f"echo '=== Initial {workdir} contents ===' && ls -la {workdir} && " f"echo '=== Running Python ===' && python {workdir}/main.py && " f"echo '=== Final {workdir} contents ===' && ls -la {workdir}", ] else: cmd = ["bash", "-lc", f"python {workdir}/main.py"] # Write code into /work all_files: dict[str, str | bytes] = {"main.py": code, **(files or {})} tar_stream = self._pack_files(all_files) # Device requests for GPU device_requests = None if self.gpu: device_requests = [docker.types.DeviceRequest(count=-1, capabilities=[["gpu"]])] container = self.client.containers.create( image=image_tag, command=cmd, working_dir=workdir, stdin_open=False, tty=False, detach=True, # Isolation knobs network_disabled=not network, mem_limit=mem_limit, nano_cpus=int(cpu_limit * 1e9), # e.g., 1.0 -> 1 core pids_limit=128, tmpfs={"/tmp": "size=1G", "/home": "size=1G"}, security_opt=[ "no-new-privileges:true", ], cap_drop=["ALL"], device_requests=device_requests, user="1000:1000", # non-root; ensure image has this uid or add it ) try: # Put files into the container container.put_archive(workdir, tar_stream) # Start & wait with timeout container.start() exit_code = container.wait(timeout=timeout_sec).get("StatusCode", 124) # Gather output stdout = container.logs(stdout=True, stderr=False).decode("utf-8", "replace") stderr = container.logs(stdout=False, stderr=True).decode("utf-8", "replace") return {"exit_code": exit_code, "stdout": stdout, "stderr": stderr} except Exception as e: # Attempt to stop if it is still running with contextlib.suppress(builtins.BaseException): container.kill() return {"exit_code": 124, "stdout": "", "stderr": f"{type(e).__name__}: {e}"} finally: with contextlib.suppress(builtins.BaseException): container.remove(force=True) def build_image(self, image: str, base_image: str, language_packages: list[str] | None = None) -> str: image_tag = self.builder.build_image(image, base_image, language_packages=language_packages) return image_tag if __name__ == "__main__": executor = DockerExecutor() executor.build_image("python", "python:3.10-slim", language_packages=["numpy"]) output = executor.run_code("print('hi')", debug=True) print("exit_code", output["exit_code"]) print("stdout", output["stdout"]) print("stderr", output["stderr"]) ================================================ FILE: agent/src/vss_agents/tools/code_executor/docker_backend/image_builder.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import atexit import io import logging import tarfile from typing import Any from typing import TypedDict import docker logger = logging.getLogger(__name__) class ImageInfo(TypedDict): image_tag: str base_image: str system_packages: list[str] language_packages: list[str] class ImageBuilder: # Class-level type annotations for attributes set in __new__ client: Any _image_cache: dict[str, ImageInfo] _image_usage_count: dict[str, int] """ ImageBuilder is a singleton class that builds Docker images for different languages. It uses the docker SDK to build the images. This singleton is shared across all tools and persists for the application lifetime. Images are only cleaned up when the process exits. """ _instance: "ImageBuilder | None" = None _cleanup_registered: bool = False def __new__(cls) -> "ImageBuilder": if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.client = docker.from_env() cls._instance._image_cache = {} cls._instance._image_usage_count = {} # Track usage count for each image # Register cleanup on process exit if not cls._cleanup_registered: atexit.register(cls._cleanup_at_exit) cls._cleanup_registered = True logger.info("ImageBuilder singleton created and cleanup registered") return cls._instance @classmethod def _cleanup_at_exit(cls) -> None: """Cleanup handler for process exit.""" if cls._instance is not None: logger.info("Running ImageBuilder cleanup at exit...") cls.reset_instance() def __del__(self) -> None: """Cleanup when the singleton is garbage collected.""" # Note: This might not always be called reliably, atexit is more reliable try: self.cleanup() except Exception as e: logger.error(f"Error during ImageBuilder cleanup: {e}") @classmethod def reset_instance(cls) -> None: """Reset the singleton instance and cleanup resources. This should only be called on application shutdown.""" if cls._instance is not None: try: cls._instance.cleanup() except Exception as e: logger.error(f"Error during cleanup: {e}") finally: cls._instance = None def cleanup(self) -> None: """Cleanup all Docker images in the cache. Warning: This removes ALL cached images. Only call on shutdown.""" logger.info(f"Cleaning up {len(self._image_cache)} cached Docker images...") removed_count = 0 failed_count = 0 for _, image_info in self._image_cache.items(): try: self.client.images.remove(image_info["image_tag"], force=True) logger.info(f"Removed Docker image: {image_info['image_tag']}") removed_count += 1 except docker.errors.ImageNotFound: logger.debug(f"Image already removed: {image_info['image_tag']}") except Exception as e: logger.warning(f"Failed to remove Docker image {image_info['image_tag']}: {e}") failed_count += 1 self._image_cache.clear() self._image_usage_count.clear() logger.info(f"Cleanup complete: {removed_count} images removed, {failed_count} failed") def _generate_dockerfile( self, base_image: str, system_packages: None | list[str] = None, language_packages: None | list[str] = None, ) -> str: """Generate Dockerfile content based on config""" # Start building Dockerfile dockerfile = [f"FROM {base_image}"] # Install system packages if system_packages: if "debian" in base_image or "ubuntu" in base_image or "python" in base_image: packages_str = " ".join(system_packages) dockerfile.extend( [ "", "# Install system dependencies", "RUN apt-get update && apt-get install -y --no-install-recommends \\", f" {packages_str} \\", " && rm -rf /var/lib/apt/lists/*", ] ) elif "alpine" in base_image: packages_str = " ".join(system_packages) dockerfile.extend(["", "# Install system dependencies", f"RUN apk add --no-cache {packages_str}"]) # Install language-specific packages if language_packages: if "python" in base_image: packages_str = " ".join(language_packages) dockerfile.extend( ["", "# Install Python packages", "RUN pip install --no-cache-dir \\", f" {packages_str}"] ) elif "node" in base_image: packages_str = " ".join(language_packages) dockerfile.extend( ["", "# Install Node.js packages globally", "RUN npm install -g \\", f" {packages_str}"] ) # Create non-root user user_uid = 1000 user_name = "executor" working_dir = "/work" dockerfile.extend( [ "", f"# Create non-root user with UID {user_uid}", f"RUN useradd -m -u {user_uid} -s /bin/bash {user_name}", "", "# Set working directory", f"WORKDIR {working_dir}", "", "# Switch to non-root user", f"USER {user_name}", "", "# Default command", 'CMD ["/bin/bash"]', ] ) return "\n".join(dockerfile) def _create_dockerfile_tar(self, dockerfile_content: str) -> bytes: """Create a tar archive containing the Dockerfile""" data = io.BytesIO() with tarfile.open(fileobj=data, mode="w") as tar: dockerfile_bytes = dockerfile_content.encode("utf-8") info = tarfile.TarInfo("Dockerfile") info.size = len(dockerfile_bytes) info.mode = 0o644 tar.addfile(info, io.BytesIO(dockerfile_bytes)) data.seek(0) return data.getvalue() def build_image( self, image: str, base_image: str, system_packages: None | list[str] = None, language_packages: None | list[str] = None, force_rebuild: bool = False, ) -> str: """Build Docker image for specified language""" image_tag = f"deep-search/{image}-executor" # Check if image already exists if not force_rebuild and image in self._image_cache: try: self.client.images.get(image_tag) logger.info(f"Image {image_tag} already exists. Using cached version.") return image_tag except docker.errors.ImageNotFound as err: raise ValueError(f"Image {image_tag} not found. Please rebuild the image.") from err logger.info(f"Building image for {image}...") # Generate Dockerfile dockerfile_content = self._generate_dockerfile(base_image, system_packages, language_packages) dockerfile_tar = self._create_dockerfile_tar(dockerfile_content) # Build image try: # Build with progress output build_logs = self.client.api.build( fileobj=io.BytesIO(dockerfile_tar), custom_context=True, tag=image_tag, rm=True, decode=True ) # Print build progress for log in build_logs: if "stream" in log: logger.info(log["stream"].strip()) logger.info(f"Successfully built image: {image_tag}") self._image_cache[image] = ImageInfo( image_tag=image_tag, base_image=base_image, system_packages=system_packages or [], language_packages=language_packages or [], ) return image_tag except docker.errors.BuildError as e: print(f"Failed to build image: {e}") raise def get_image_tag(self, image: str) -> str | None: """Get the image tag for the specified language""" return self._image_cache[image]["image_tag"] def get_all_images(self) -> dict[str, ImageInfo]: """Get all images' information Returns: dict[str, ImageInfo]: A dictionary of image information, the key is the image name, the value is the image information """ return self._image_cache ================================================ FILE: agent/src/vss_agents/tools/code_executor/python_executor.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging import random import string from typing import Literal from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.tools.code_executor import DockerExecutor logger = logging.getLogger(__name__) class CodeExecutorConfig(FunctionBaseConfig, name="python_executor"): """Configuration for the Code Executor tool.""" backend: Literal["docker"] = Field( "docker", description="Executor backend to be used", ) gpu: bool = Field( False, description="Whether to use GPU in the container, only valid when backend is docker", ) base_image: str = Field( ..., description="The base image of the runtime to be used, for example, 'python:3.11-slim'", ) language_packages: list[str] = Field( ..., description="The packages to be installed in the container, for example, ['numpy', 'pandas']", ) class CodeExecutorInput(BaseModel): """Input for the Code Executor tool""" code: str | None = Field( None, description="The code to be executed, only valid when action is run", ) files: dict[str, str] = Field( ..., description="The files to be mounted to the container, only valid when action is run", ) class CodeExecutorOutput(BaseModel): """Output for the Code Executor tool""" message: str = Field(..., description="The output of the code execution") @register_function(config_type=CodeExecutorConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def python_executor(config: CodeExecutorConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: """A tool that executes python code in a container Args: files: a dictionary of file paths and their contents, which will be mounted to the container and used by code code: the code to be executed, only valid when action is run Returns: AsyncGenerator[FunctionInfo, None]: A generator of FunctionInfo """ # TODO: add executor backend for k8s # make a random name string for the image (lowercase only for Docker compatibility) image_name = "python-" + "".join(random.choices(string.ascii_lowercase + string.digits, k=10)) if config.backend == "docker": executor = DockerExecutor(gpu=config.gpu) # build images from the config logger.info(f"Building image {config.base_image}") executor.build_image(image_name, config.base_image, config.language_packages) logger.info(f"Built images: {executor.builder.get_all_images()}") async def _python_executor(code_executor_input: CodeExecutorInput) -> CodeExecutorOutput: """ this tool first mount files' content to the container, based on the relative path, then run the code in the container, and return the output(stdout, stderr) Args: code_executor_input (CodeExecutorInput): The input for the Code Executor tool Returns: CodeExecutorOutput: The output of the code execution, if the code execution is successful, the message will be the stdout ONLY, otherwise the message will include stdout and stderr """ code = code_executor_input.code or "" output = executor.run_code(code, code_executor_input.files, image=image_name) if output["exit_code"] == 0: return CodeExecutorOutput(message=f"{output['stdout']}") else: return CodeExecutorOutput(message=f"Error: {output}") yield FunctionInfo.create( single_fn=_python_executor, description="Execute code in a container", input_schema=CodeExecutorInput, single_output_schema=CodeExecutorOutput, ) ================================================ FILE: agent/src/vss_agents/tools/embed_search.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio from collections.abc import AsyncGenerator from datetime import UTC from datetime import datetime import json import logging import re from typing import TYPE_CHECKING from typing import Any from typing import Literal from elasticsearch import AsyncElasticsearch from elasticsearch import NotFoundError as ESNotFoundError from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.api_server import ChatRequest from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.embed.cosmos_embed import CosmosEmbedClient from vss_agents.tools.vst.snapshot import build_screenshot_url from vss_agents.utils.time_convert import datetime_to_iso8601 from vss_agents.utils.time_convert import iso8601_to_datetime if TYPE_CHECKING: from vss_agents.embed.embed import EmbedClient # Base timestamp BASE_2025 = datetime(2025, 1, 1, tzinfo=UTC) logger = logging.getLogger(__name__) def _sanitize_for_logging(obj: Any) -> Any: """Remove embedding vectors from objects for logging purposes. Recursively traverses dictionaries and lists to remove 'vector' fields and 'query_vector' fields while preserving all other data. Args: obj: Object to sanitize (dict, list, or other) Returns: Sanitized object with embeddings removed """ if isinstance(obj, dict): sanitized = {} for key, value in obj.items(): if key in ("vector", "query_vector"): # Replace embedding vectors with a placeholder if isinstance(value, list) and len(value) > 0: sanitized[key] = f"" else: sanitized[key] = "" elif key == "embeddings" and isinstance(value, list): # Replace embeddings list with summary sanitized[key] = f"" else: sanitized[key] = _sanitize_for_logging(value) return sanitized elif isinstance(obj, list): return [_sanitize_for_logging(item) for item in obj] else: return obj # Flat output models (replacing nested VisionLLM hierarchy) class EmbedSearchResultItem(BaseModel): """A single embed search result with all fields extracted.""" video_name: str = Field(default="", description="Video filename") description: str = Field(default="", description="Video/sensor description") start_time: str = Field(default="", description="Start time (ISO format)") end_time: str = Field(default="", description="End time (ISO format)") sensor_id: str = Field(default="", description="Sensor/stream UUID") screenshot_url: str = Field(default="", description="Screenshot URL") similarity_score: float = Field(default=0.0, description="Cosine similarity score") class EmbedSearchOutput(BaseModel): """Output of embed search.""" query_embedding: list[float] = Field(default_factory=list, description="Query embedding vector") results: list[EmbedSearchResultItem] = Field(default_factory=list, description="Search results") class QueryInput(BaseModel): """Query input model for schema validation.""" id: str = Field(default="", description="Query ID") params: dict[str, str] = Field(default_factory=dict, description="Query parameters") prompts: dict[str, str] = Field(default_factory=dict, description="Query prompts") response: str = Field(default="", description="Query response") embeddings: list[dict[str, Any]] = Field(default_factory=list, description="Query embeddings") source_type: Literal["video_file", "rtsp"] = Field( ..., description="Type of video source: 'video_file' for uploaded videos, 'rtsp' for live/camera streams.", ) exclude_videos: list[dict[str, str]] = Field( default_factory=list, description="List of videos to exclude from results" ) class EmbedSearchConfig(FunctionBaseConfig, name="embed_search"): """Configuration for the Embed Search tool.""" cosmos_embed_endpoint: str = Field( ..., description="The URL of the backend to use for video ingestion.", ) es_endpoint: str = Field( ..., description="The URL of the Elasticsearch endpoint to use for video ingestion.", ) es_index: str = Field( default="video_embeddings", description="The index of the Elasticsearch to use for video ingestion.", ) vst_external_url: str = Field( ..., description="The external VST URL for client-facing URLs.", ) vst_internal_url: str | None = Field( default=None, description="The internal VST URL for validation requests. If not provided, uses vst_external_url.", ) default_max_results: int = Field( default=100, description="Maximum number of results to return when top_k is not specified.", ) # NOTE: video_clip_tool removed - UI calls VST API directly for video overlays def _str_input_converter(input: str) -> QueryInput: """Convert string input to QueryInput Pydantic model.""" try: input_dict = json.loads(input) logger.info(f"Input dict: {input_dict}") # If it's already a Query JSON format, create QueryInput directly if "params" in input_dict or "prompts" in input_dict: return QueryInput(**input_dict) else: # Not in Query format, treat entire input as query string logger.warning(f"Input not in Query format, treating as query string: {input}") return QueryInput(id="", params={"query": input}, source_type="video_file") except Exception as e: logger.exception(f"Error parsing input to QueryInput, using as query string: {input}, error: {e}") return QueryInput(id="", params={"query": input}, source_type="video_file") def _chat_request_input_converter(request: ChatRequest) -> QueryInput: """Convert ChatRequest to QueryInput Pydantic model.""" try: content = request.messages[-1].content input_dict = json.loads(content) logger.info(f"Input dict: {input_dict}") # If it's already a Query JSON format, create QueryInput directly if "params" in input_dict or "prompts" in input_dict: return QueryInput(**input_dict) else: # Not in Query format, treat entire content as query string logger.warning(f"Input not in Query format, treating as query string: {content}") return QueryInput(id="", params={"query": content}, source_type="video_file") except Exception as e: logger.exception( f"Error parsing input to QueryInput, using as query string: {request.messages[-1].content}, error: {e}" ) return QueryInput(id="", params={"query": request.messages[-1].content}, source_type="video_file") def _to_str_output(output: EmbedSearchOutput) -> str: """Convert EmbedSearchOutput to JSON string.""" return output.model_dump_json() async def _generate_query_embedding(query_input: QueryInput, embed_client: "EmbedClient") -> list[float]: """Step 1: Generate query embedding from the appropriate source. Args: query_input: The query input containing text, image_url, video_url, or pre-computed embeddings embed_client: The embedding client to use Returns: Query embedding vector as list of floats """ if query_input.embeddings: # Use pre-computed embedding if provided vector = query_input.embeddings[0].get("vector", []) if isinstance(vector, list): return [float(v) for v in vector] return [] image_url = query_input.params.get("image_url", "") query_text = query_input.params.get("query", "") video_url = query_input.params.get("video_url", "") if image_url: return await embed_client.get_image_embedding(image_url) elif query_text: return await embed_client.get_text_embedding(query_text.strip()) elif video_url: return await embed_client.get_video_embedding(video_url) else: raise ValueError("Either query, image_url, video_url, or embeddings must be provided in Query params.") def _build_es_query(query_input: QueryInput, query_embedding: list[float], config: EmbedSearchConfig) -> dict[str, Any]: """Build Elasticsearch query body. Args: query_input: The query input with filter parameters query_embedding: The query embedding vector config: Embed search configuration Returns: The search query body. """ # Extract parameters from QueryInput video_sources_str = query_input.params.get("video_sources", "") top_k_str = query_input.params.get("top_k", "") top_k: int | None = int(top_k_str) if top_k_str else None min_cosine_similarity = float(query_input.params.get("min_cosine_similarity", "0.0")) description = query_input.params.get("description", "") timestamp_start_str = query_input.params.get("timestamp_start", "") timestamp_end_str = query_input.params.get("timestamp_end", "") # Parse video_sources if provided (can be JSON string or comma-separated) video_sources: list[str] = [] if video_sources_str: try: # Try parsing as JSON array parsed = json.loads(video_sources_str) if isinstance(parsed, list): video_sources = [str(v) for v in parsed] else: # If JSON parsing succeeded but result is not a list, treat as comma-separated video_sources = [v.strip() for v in video_sources_str.split(",") if v.strip()] except Exception: # Try comma-separated string video_sources = [v.strip() for v in video_sources_str.split(",") if v.strip()] # Parse timestamps if provided timestamp_start: datetime | None = None timestamp_end: datetime | None = None if timestamp_start_str: try: user_ts = iso8601_to_datetime(timestamp_start_str) timestamp_start = user_ts except Exception as e: logger.warning(f"Failed to parse timestamp_start: {e}") if timestamp_end_str: try: user_ts = iso8601_to_datetime(timestamp_end_str) timestamp_end = user_ts except Exception as e: logger.warning(f"Failed to parse timestamp_end: {e}") # Build filter conditions filters: list[dict[str, Any]] = [] # Add video_sources filter if provided if video_sources: should_clauses = [] for vname in video_sources: escaped_vname = vname.replace("\\", "\\\\").replace("*", "\\*").replace("?", "\\?") # Check sensor.id (for RTSP streams and video files) should_clauses.append({"term": {"sensor.id.keyword": vname}}) should_clauses.append({"wildcard": {"sensor.id.keyword": f"*{escaped_vname}*"}}) # Check sensor.info.url (for uploaded video files) should_clauses.append({"wildcard": {"sensor.info.url.keyword": f"*{escaped_vname}"}}) should_clauses.append({"wildcard": {"sensor.info.url.keyword": f"*{escaped_vname}*"}}) # Check sensor.info.path (for RTSP streams - contains UUID) should_clauses.append({"wildcard": {"sensor.info.path.keyword": f"*{escaped_vname}*"}}) regex_escaped = re.escape(vname) should_clauses.append({"regexp": {"sensor.info.url": f".*{regex_escaped}"}}) should_clauses.append({"regexp": {"sensor.info.path": f".*{regex_escaped}"}}) filters.append( { "bool": { "should": should_clauses, "minimum_should_match": 1, } } ) # Add description filter if description: escaped_desc = description.replace("\\", "\\\\").replace("*", "\\*").replace("?", "\\?") regex_escaped_desc = re.escape(description) description_should_clauses = [ {"match": {"sensor.description": description}}, {"wildcard": {"sensor.description.keyword": f"*{escaped_desc}*"}}, {"wildcard": {"sensor.description.keyword": f"*{escaped_desc}"}}, {"regexp": {"sensor.description": f".*{regex_escaped_desc}.*"}}, {"regexp": {"sensor.description.keyword": f".*{regex_escaped_desc}.*"}}, ] filters.append( { "bool": { "should": description_should_clauses, "minimum_should_match": 1, } } ) # Add timestamp range filter if timestamp_start or timestamp_end: must_clauses = [] if timestamp_start: must_clauses.append({"range": {"timestamp": {"gte": timestamp_start.isoformat()}}}) if timestamp_end: must_clauses.append({"range": {"end": {"lte": timestamp_end.isoformat()}}}) if len(must_clauses) > 1: filters.append({"bool": {"must": must_clauses}}) else: filters.append(must_clauses[0]) # Adjust k based on filters and similarity threshold if top_k is None: k_value = config.default_max_results elif min_cosine_similarity >= -1.0 or filters: k_value = top_k * 5 else: k_value = top_k num_candidates = k_value * 2 # Build nested KNN query knn_query: dict[str, Any] = { "field": "llm.visionEmbeddings.vector", "query_vector": query_embedding, "k": k_value, "num_candidates": num_candidates, } # Build nested query wrapping the KNN query nested_query: dict[str, Any] = { "nested": { "path": "llm.visionEmbeddings", "query": { "knn": knn_query, }, "inner_hits": { "size": 1, }, } } # Build search query with filters if filters: if len(filters) > 1: filter_clause = {"bool": {"must": filters}} else: filter_clause = filters[0] search_query = { "query": { "bool": { "must": [nested_query], "filter": [filter_clause], } }, "size": k_value, } else: search_query = { "query": nested_query, "size": k_value, } logger.debug(f"ES search_query:\n{json.dumps(search_query, indent=2)}") logger.info(f"Search query: {_sanitize_for_logging(search_query)}") return search_query async def _process_search_hit( hit: dict[str, Any], config: EmbedSearchConfig, min_cosine_similarity: float, exclude_videos: list[dict[str, str]] ) -> EmbedSearchResultItem | None: """Step 3: Process a single ES search hit into an EmbedSearchResultItem. Args: hit: A single Elasticsearch search hit config: Embed search configuration min_cosine_similarity: Minimum cosine similarity threshold exclude_videos: List of videos to exclude from results (sensor_id, start_timestamp, end_timestamp) Returns: EmbedSearchResultItem if hit passes filters, None otherwise """ try: # ES score is normalized to [0, 1] range, UI sends min_cosine_similarity in [-1, 1] range # Convert ES score to cosine: cosine = (2 * _score) - 1 # Round to 2 decimal places before comparing to avoid floating-point precision issues # (e.g., 2 * 0.60 - 1 = 0.19999... which would incorrectly fail a 0.20 threshold check) similarity_score = round(2 * hit["_score"] - 1, 2) if similarity_score < min_cosine_similarity: return None source = hit["_source"] # Only process results with "llm" field if "llm" not in source: logger.warning(f"Skipping result without 'llm' field: {hit.get('_id', 'unknown')}") return None # Parse the stored VisionLLM structure stored_llm_data = source.get("llm", {}) or {} queries_data = stored_llm_data.get("queries", []) if not isinstance(queries_data, list): queries_data = [] # Extract fields from stored data sensor_data = source.get("sensor", {}) or {} sensor_info = sensor_data.get("info", {}) or {} video_path = sensor_info.get("path", "") or sensor_info.get("url", "") sensor_id_raw = sensor_data.get("id", "") # Could be sensor name (RTSP) or UUID (video_file) # ============================================================================================ # Extract stream_id (UUID) - ALWAYS return UUID when available # ============================================================================================ # RTSP stream: sensor.id = sensor name (e.g., "warehouse_sample_test") # sensor.info.path = "rtsp://.../live/ea965db6-a8d4-4108-9917-bf820eeb8a98" # sensor.stream_id = UUID (if present) # → Extract UUID from path (always available for RTSP) # Video file: sensor.id = UUID (e.g., "8fce43a6-1c35-4d6a-b6e3-391c42090a87") # sensor.info.path = "/tmp/assets/8fce43a6-.../boxcart_1.mp4" # sensor.stream_id = UUID (if present) # → Extract UUID from path, or use sensor.id/sensor.stream_id if it's a UUID # ============================================================================================ stream_id = None # Priority 1: Check sensor.stream_id field (if present, it's the UUID) sensor_stream_id = sensor_data.get("stream_id", "") if sensor_stream_id: is_uuid = len(sensor_stream_id) == 36 and sensor_stream_id.count("-") == 4 if is_uuid: stream_id = sensor_stream_id logger.debug(f"Found UUID in sensor.stream_id: {stream_id}") # Priority 2: Extract UUID from sensor.info.path (works for both RTSP and video files) if not stream_id and video_path: uuid_pattern = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" uuid_match = re.search(uuid_pattern, video_path, re.IGNORECASE) if uuid_match: stream_id = uuid_match.group(0) # UUID found in path logger.debug(f"Extracted UUID from path: {stream_id}") # Priority 3: If no UUID in path, check if sensor.id is a UUID (video file case) if not stream_id: is_uuid = len(sensor_id_raw) == 36 and sensor_id_raw.count("-") == 4 if is_uuid: # Video file: sensor.id IS the UUID stream_id = sensor_id_raw logger.debug(f"Using sensor.id as UUID: {stream_id}") else: # RTSP stream: sensor.id is sensor name, but UUID should be in path # If we reach here, UUID extraction from path failed - log warning logger.warning( f"Could not extract UUID from path '{video_path}' or sensor.stream_id for sensor '{sensor_id_raw}'. " "Using sensor.id as stream_id." ) stream_id = ( sensor_id_raw # Fallback: sensor name (fusion_search_rerank will use as-is for attribute_search) ) # Start with response_data from stored query response_data: dict[str, Any] = {} if queries_data and len(queries_data) > 0: stored_query_data = queries_data[0] if isinstance(queries_data[0], dict) else {} response_str = stored_query_data.get("response", "{}") if response_str: try: parsed = json.loads(response_str) if isinstance(parsed, dict): response_data = parsed except Exception: pass # ============================================================================================ # Extract video_name - different logic for RTSP vs video_file # ============================================================================================ # RTSP stream: video_name = sensor.id (sensor name, e.g., "warehouse_sample_test") # Video file: video_name = filename from path (e.g., "boxcart_1_20250101_000000_c9b20.mp4") # ============================================================================================ video_name = response_data.get("video_name", "") if not video_name: is_uuid = len(sensor_id_raw) == 36 and sensor_id_raw.count("-") == 4 if is_uuid: # Video file: extract filename from path if video_path: video_name = video_path.split("/")[-1] # e.g., "boxcart_1_20250101_000000_c9b20.mp4" else: video_name = sensor_id_raw # Fallback to UUID if no path else: # RTSP stream: use sensor name as video_name video_name = sensor_id_raw if sensor_id_raw else "" # 2. Extract description from sensor.description only description = response_data.get("description", "") if not description: description = sensor_data.get("description", "") # 3. Extract timestamps # Extract start_time from source.timestamp start_time = response_data.get("start_time", "") if not start_time: es_timestamp = source.get("timestamp", "") if es_timestamp: try: es_start_dt = iso8601_to_datetime(str(es_timestamp)) start_time = datetime_to_iso8601(es_start_dt) except Exception as e: logger.warning(f"Failed to parse timestamp: {e}") start_time = datetime_to_iso8601(BASE_2025) else: start_time = datetime_to_iso8601(BASE_2025) # Extract end_time from source.end end_time = response_data.get("end_time", "") if not end_time: es_end = source.get("end", "") if es_end: try: es_end_dt = iso8601_to_datetime(str(es_end)) end_time = datetime_to_iso8601(es_end_dt) except Exception as e: logger.warning(f"Failed to parse end timestamp: {e}") end_time = datetime_to_iso8601(BASE_2025) else: end_time = datetime_to_iso8601(BASE_2025) logger.debug(f"Final timestamps - start_time: {start_time}, end_time: {end_time}, stream_id: {stream_id}") # Check if this result is in the exclude_videos list # TODO: make this more efficient for exclude_video in exclude_videos: if ( sensor_id_raw == exclude_video.get("sensor_id", "") and start_time == exclude_video.get("start_timestamp", "") and end_time == exclude_video.get("end_timestamp", "") ): return None # 4. Build screenshot URL if stream_id is available screenshot_url = "" if stream_id: screenshot_url = build_screenshot_url( config.vst_external_url, stream_id, start_time, ) return EmbedSearchResultItem( video_name=video_name, description=description, start_time=start_time, end_time=end_time, sensor_id=stream_id, screenshot_url=screenshot_url, similarity_score=similarity_score, ) except Exception as e: logger.warning(f"Error processing search hit: {e}") return None @register_function(config_type=EmbedSearchConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def embed_search(config: EmbedSearchConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: logger.info(f"Embed search config: {config}") es_client = AsyncElasticsearch(config.es_endpoint) embed_client: EmbedClient = CosmosEmbedClient(config.cosmos_embed_endpoint) async def _embed_search(query_input: QueryInput) -> EmbedSearchOutput: """Perform embedding search using QueryInput and return EmbedSearchOutput.""" # Index check and search_index by source_type (before generating embedding) es_index_exists = await es_client.indices.exists(index=config.es_index) source_type = query_input.source_type if source_type == "video_file": if not es_index_exists: raise ValueError( f"Search index '{config.es_index}' does not exist. " "Please ensure videos have been ingested before searching." ) search_index: str | list[str] = config.es_index else: # rtsp: if index does not exist, exclude es_index from search_index list if es_index_exists: search_index = ["mdx-embed-filtered-*", "-" + config.es_index] else: search_index = ["mdx-embed-filtered-*"] logger.info(f"Search index(es): {search_index} (source_type={source_type})") # Step 1: Generate embedding query_embedding = await _generate_query_embedding(query_input, embed_client) # Step 2: Build ES query search_query = _build_es_query(query_input, query_embedding, config) # Execute ES search try: response = await es_client.search(index=search_index, body=search_query) except ESNotFoundError as e: logger.error(f"Elasticsearch index '{search_index}' not found: {e}") raise ValueError( f"Search index '{search_index}' does not exist. " "Please ensure videos have been ingested before searching." ) from e # Log response response_dict = response.body logger.info( f"ES search response (before processing): {json.dumps(_sanitize_for_logging(response_dict), indent=2)}" ) # Step 3: Process hits in parallel hits = response["hits"]["hits"] min_sim = float(query_input.params.get("min_cosine_similarity", "0.0")) tasks = [_process_search_hit(hit, config, min_sim, query_input.exclude_videos) for hit in hits] processed = await asyncio.gather(*tasks) results = [r for r in processed if r is not None] # Apply top_k limit top_k_str = query_input.params.get("top_k", "") if top_k_str: results = results[: int(top_k_str)] logger.info(f"Found {len(results)} videos matching the query") logger.info( f"Embed search result (after processing): {json.dumps(_sanitize_for_logging(EmbedSearchOutput(query_embedding=query_embedding, results=results).model_dump()), indent=2)}" ) return EmbedSearchOutput(query_embedding=query_embedding, results=results) yield FunctionInfo.create( single_fn=_embed_search, description=_embed_search.__doc__, input_schema=QueryInput, single_output_schema=EmbedSearchOutput, converters=[ _str_input_converter, _chat_request_input_converter, _to_str_output, ], ) ================================================ FILE: agent/src/vss_agents/tools/evaluation_compressor.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging import re from typing import Any from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import LLMRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) EVALUATION_COMPRESSOR_PROMPT = """ You are an expert at summarizing and compressing text for evaluation purposes. Your compressed text will be sent to and evaluator which will judge the quality of the agent's response. Your task is to compress the provided text while preserving all key information and important details for quality evaluation. Do not omit any critical facts or context. Return the compressed text in markdown paragraph formatting. RULES: - Compress each video caption block that is returned from a video caption tool call into one short paragraph, concisely and accurately describing the captions. - Summarize agent's and supervisor agent's thoughts, actions, decisions and tool calls concisely. - For the agent's final conclusion and final answer to the user's query, do not compress and keep the original text. - Maintain the original order of the text. """ class EvaluationCompressorConfig(FunctionBaseConfig, name="evaluation_compressor"): """Configuration for the Evaluation Compressor tool.""" llm_name: LLMRef = Field(..., description="The LLM to use to compress the agent output.") token_limit: int = Field(..., description="The token limit for the agent output.") remove_caption_details: bool = Field( default=True, description="Whether to remove caption details from the agent output." ) class EvaluationCompressorInput(BaseModel): input_text: str = Field(..., description="The input text to compress.") def remove_caption_details(text: str) -> str: """ Removes paragraphs from the text that start with a timestamp in the format [float_number] followed by text. Args: text (str): The input text. Returns: str: The text with caption details removed. """ # Pattern: [float] at the start of a line, possibly with leading spaces, followed by any text pattern = re.compile(r"^\s*\[\d+\.\d+\].*$", re.MULTILINE) cleaned_text = re.sub(pattern, "", text) # remove any resulting multiple blank lines cleaned_text = re.sub(r"\n{2,}", "\n\n", cleaned_text).strip() return cleaned_text def count_sections_by_token_limit(input_text: str, token_limit: int, llm_model: str) -> int: """ Returns the number of sections needed to split input_text such that each section contains at most token_limit tokens. Uses tiktoken to count tokens. """ import tiktoken try: enc = tiktoken.encoding_for_model(llm_model) except KeyError: logger.warning(f"Model {llm_model} not found in tiktoken. Using gpt-4o as fallback.") enc = tiktoken.encoding_for_model("gpt-4o") token_count = len(enc.encode(input_text)) num_sections = (token_count + token_limit - 1) // token_limit return num_sections def split_text_by_sections(input_text: str, num_sections: int) -> list: """ Splits input_text into num_sections, trying to make each section as equal in size as possible, but only splitting at paragraph boundaries (i.e., after a double newline or single newline if no double found). Returns a list of section strings. """ # Split into paragraphs (preserve newlines) # We'll treat paragraphs as blocks separated by at least one blank line import re paragraphs = re.split(r"\n\s*\n", input_text.strip()) total_paragraphs = len(paragraphs) print(f"!!! TOTAL PARAGRAPHS: {total_paragraphs}") if num_sections <= 0: raise ValueError("num_sections must be a positive integer") if num_sections > total_paragraphs: # If more sections than paragraphs, just return each paragraph as a section, pad with empty strings return [p.strip() for p in paragraphs] + [""] * (num_sections - total_paragraphs) # Calculate how many paragraphs per section (as evenly as possible) base = total_paragraphs // num_sections remainder = total_paragraphs % num_sections sections = [] idx = 0 for i in range(num_sections): # Distribute the remainder: first 'remainder' sections get one extra paragraph count = base + (1 if i < remainder else 0) section_paragraphs = paragraphs[idx : idx + count] section_text = "\n\n".join(section_paragraphs).strip() if len(section_text) > 0: sections.append(section_text) idx += count return sections @register_function(config_type=EvaluationCompressorConfig) async def evaluation_compressor(config: EvaluationCompressorConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """ This tool is used to compress the agent output if it exceeds the token limit. """ llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) async def _evaluation_compressor(evaluation_compressor_input: EvaluationCompressorInput) -> str: # Get rid of caption details if bool set to True intial_text = ( remove_caption_details(evaluation_compressor_input.input_text) if config.remove_caption_details else evaluation_compressor_input.input_text ) num_sections = count_sections_by_token_limit(intial_text, config.token_limit, llm.model_name) # Check if the initial text is within the token limit if num_sections <= 1: return intial_text # If token count is still too high then run compression in parallel section_list = split_text_by_sections(intial_text, num_sections) # Call LLM in parallel on each section import asyncio async def compress_section(section: str) -> Any: messages = [ SystemMessage(content=EVALUATION_COMPRESSOR_PROMPT), HumanMessage(content=f"The text to compress is:\n\n{section}"), ] compressed_section = await llm.ainvoke(messages) return compressed_section.content compressed_sections = await asyncio.gather(*[compress_section(section) for section in section_list]) # Combine all LLM results compressed_text = "\n\n".join(compressed_sections) # Check if the compressed text is within the token limit final_num_sections = count_sections_by_token_limit(compressed_text, config.token_limit, llm.model_name) if final_num_sections > 1: # If still too long, compress again by calling compress_section on the full compressed_text compressed_text = await compress_section(compressed_text) # Return shortened text return compressed_text yield FunctionInfo.create( single_fn=_evaluation_compressor, description=_evaluation_compressor.__doc__, input_schema=EvaluationCompressorInput, single_output_schema=str, ) ================================================ FILE: agent/src/vss_agents/tools/fov_counts_with_chart.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator from datetime import datetime import logging from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class FOVCountsWithChartConfig(FunctionBaseConfig, name="get_fov_counts_with_chart"): """Configuration for FOV counts with automatic chart generation.""" get_fov_histogram_tool: FunctionRef = Field( ..., description="The tool to use for getting FOV histogram data", ) chart_generator_tool: FunctionRef = Field( ..., description="The tool to use for generating charts", ) chart_base_url: str = Field( default="http://localhost:38000/reports/", description="Base URL for accessing stored chart images", ) class FOVCountsWithChartInput(BaseModel): """Input for FOV counts with chart generation.""" sensor_id: str = Field(..., description="Sensor ID to fetch counts from") start_time: str = Field( ..., description="Start time in ISO format (e.g., '2025-10-14T14:00:00.000Z')", ) end_time: str = Field( ..., description="End time in ISO format (e.g., '2025-10-14T14:01:00.000Z')", ) object_type: str | None = Field( default=None, description="Object type to count (e.g., 'Person'). If not specified, returns counts for all object types.", ) bucket_count: int = Field( default=10, description="Number of time buckets for histogram (default: 10)", ) class FOVCountsWithChartOutput(BaseModel): """Output from FOV counts with chart generation.""" summary: str = Field(..., description="Summary of the count data") latest_count: int = Field(..., description="Most recent object count") average_count: float = Field(..., description="Average count across all time bins") chart_url: str | None = Field(None, description="URL to the generated chart image") raw_histogram: dict = Field(..., description="Raw histogram data from the API") @register_function(config_type=FOVCountsWithChartConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def get_fov_counts_with_chart(config: FOVCountsWithChartConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """Get FOV histogram data and automatically generate a visualization chart.""" # Get the tools get_fov_histogram_tool = await builder.get_tool( config.get_fov_histogram_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN ) chart_generator_tool = await builder.get_tool(config.chart_generator_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) async def _get_fov_counts_with_chart(input_data: FOVCountsWithChartInput) -> FOVCountsWithChartOutput: """Main implementation.""" import json logger.info( f"Getting FOV histogram for sensor {input_data.sensor_id} from {input_data.start_time} to {input_data.end_time}" ) # Step 1: Get FOV histogram data tool_input = { "source": input_data.sensor_id, "start_time": input_data.start_time, "end_time": input_data.end_time, "bucket_count": input_data.bucket_count, } if input_data.object_type: tool_input["object_type"] = input_data.object_type fov_result = await get_fov_histogram_tool.ainvoke(tool_input) # Parse the result if it's a string if isinstance(fov_result, str): fov_data = json.loads(fov_result) else: fov_data = fov_result logger.debug(f"FOV counts result: {fov_data}") # Step 2: Parse histogram data histogram = fov_data.get("histogram", []) if not histogram: return FOVCountsWithChartOutput( summary="No data available for the specified time range", latest_count=0, average_count=0.0, chart_url=None, raw_histogram=fov_data, ) # Extract counts and time labels x_categories = [] counts = [] for entry in histogram: start_time = entry.get("start", "") # Format time to show only HH:MM:SS instead of full ISO timestamp try: dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) formatted_time = dt.strftime("%H:%M:%S") x_categories.append(formatted_time) except (ValueError, AttributeError): # Fallback to original if parsing fails x_categories.append(start_time) # Get the count for the specified object type (or sum all if not specified) objects = entry.get("objects", []) count = 0 if input_data.object_type: # Filter by specific object type for obj in objects: if obj.get("type") == input_data.object_type: count = int(obj.get("averageCount", 0)) break else: # Sum all object types for obj in objects: count += int(obj.get("averageCount", 0)) counts.append(count) latest_count = counts[-1] if counts else 0 average_count = sum(counts) / len(counts) if counts else 0.0 logger.info( f"Parsed {len(counts)} histogram entries. Latest count: {latest_count}, Average: {average_count:.1f}" ) # Step 3: Generate chart object_label = input_data.object_type if input_data.object_type else "All Objects" chart_input = { "charts_data": [ { "chart_file_format": "png", "title": f"{object_label} Count at {input_data.sensor_id}", "x_categories": x_categories, "series": {"Count": counts}, "x_label": "Time", "y_label": "Count", } ], "output_dir": "fov_charts", "file_prefix": f"fov_{input_data.sensor_id}_", } logger.debug(f"Calling chart_generator with input: {chart_input}") chart_result = await chart_generator_tool.ainvoke(chart_input) logger.debug(f"Chart generator returned: {chart_result}") # Parse chart result chart_url = None if isinstance(chart_result, str): # Chart result is HTML with img tag import re url_match = re.search(r'src="([^"]+)"', chart_result) chart_url = url_match.group(1) if url_match else None elif isinstance(chart_result, list) and len(chart_result) > 0: # Result is a list of ChartGenExecOutput first_chart = chart_result[0] if hasattr(first_chart, "object_store_key") and first_chart.object_store_key: chart_url = f"{config.chart_base_url}{first_chart.object_store_key}" logger.info(f"Chart generated successfully. URL: {chart_url}") # Create summary with embedded chart summary = ( f"Object counts for {input_data.sensor_id} over {len(histogram)} time intervals:\n" f"- Latest count: {latest_count} {object_label}\n" f"- Average count: {average_count:.1f} {object_label}\n" f"- Time range: {input_data.start_time} to {input_data.end_time}" ) # Embed the chart directly in the summary if available if chart_url: summary += f"\n\n![{object_label} Count Chart]({chart_url})" return FOVCountsWithChartOutput( summary=summary, latest_count=latest_count, average_count=average_count, chart_url=chart_url, raw_histogram=fov_data, ) yield FunctionInfo.create( single_fn=_get_fov_counts_with_chart, description="Get field-of-view object counts for a sensor and generate a visualization chart. Returns both count statistics and a chart image.", input_schema=FOVCountsWithChartInput, single_output_schema=FOVCountsWithChartOutput, ) ================================================ FILE: agent/src/vss_agents/tools/geolocation.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging from typing import Any import aiohttp from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class GeolocationConfig(FunctionBaseConfig, name="geolocation"): """Configuration for the geolocation information tool.""" timeout: int = Field(default=10, description="Request timeout in seconds for the OpenStreetMap API call.") class GeolocationInput(BaseModel): """Input for the geolocation information tool.""" latitude: float = Field(..., description="Latitude coordinate of the location") longitude: float = Field(..., description="Longitude coordinate of the location") class GeolocationOutput(BaseModel): """Output from the geolocation information tool.""" # Reference: https://nominatim.org/release-docs/latest/api/Output/#geocodejson type: str | None = Field( default=None, description="The 'address level' of the object (house, street, district, city, county, state, country, locality). ", ) city: str | None = Field(default=None, description="City name where the coordinates are located. ") county: str | None = Field(default=None, description="County name where the coordinates are located. ") state: str | None = Field(default=None, description="State name where the coordinates are located. ") country: str | None = Field(default=None, description="Country name where the coordinates are located. ") road: str | None = Field(default=None, description="Road name where the coordinates are located. ") speed_limit: str | None = Field(default=None, description="Speed limit at the location. ") full_address: str | None = Field(default=None, description="Full address of the location. ") category: str | None = Field( default=None, description="OpenStreetMap feature category defining the broad type (e.g. boundary, highway, amenity). ", ) subtype_within_category: str | None = Field( default=None, description="Specific feature subtype (e.g. residential, restaurant) within the OpenStreetMap category. ", ) @register_function(config_type=GeolocationConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def geolocation(config: GeolocationConfig, __builder: Builder) -> AsyncGenerator[FunctionInfo]: """Tool for getting geolocation information from latitude and longitude coordinates.""" def _extract_location_info(geo_data: dict[str, Any]) -> dict[str, Any]: """Extract structured location information from GeocodeJSON response.""" try: geocoding = geo_data["features"][0]["properties"]["geocoding"] except Exception: return { "type": None, "city": None, "county": None, "state": None, "country": None, "road": None, "speed_limit": None, "full_address": None, "category": None, "subtype_within_category": None, } speed_limit = (geocoding.get("extra") or {}).get("maxspeed", None) # Convert speed_limit to string speed_limit = None if speed_limit is None else str(speed_limit) return { "type": geocoding.get("type", None), "city": geocoding.get("city", None), "county": geocoding.get("county", None), "state": geocoding.get("state", None), "country": geocoding.get("country", None), "road": geocoding.get("name", None), "speed_limit": speed_limit, "full_address": geocoding.get("label", None), "category": geocoding.get("osm_key", None), "subtype_within_category": geocoding.get("osm_value", None), } async def _geolocation(geo_input: GeolocationInput) -> GeolocationOutput: """ Get geolocation information from latitude and longitude coordinates. Returns: Location information including road details, speed limits, and OpenStreetMap feature classification. """ async with aiohttp.ClientSession() as session: # Get reverse geocoding information from OpenStreetMap url = "https://nominatim.openstreetmap.org/reverse" params: dict[str, str | int | float] = { "lat": geo_input.latitude, "lon": geo_input.longitude, "format": "geocodejson", "addressdetails": 1, "extratags": 1, } headers = {"User-Agent": "GeoLocation-Tool/1.0"} try: async with session.get( url, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=config.timeout) ) as response: if response.status == 200: geo_data = await response.json() else: raise RuntimeError( f"Failed to fetch location data: Nominatim API returned HTTP {response.status}. " ) except Exception as e: raise RuntimeError(f"Failed to fetch location data: {e}") from e location_info = _extract_location_info(geo_data) return GeolocationOutput( type=location_info["type"], city=location_info["city"], county=location_info["county"], state=location_info["state"], country=location_info["country"], road=location_info["road"], speed_limit=location_info["speed_limit"], full_address=location_info["full_address"], category=location_info["category"], subtype_within_category=location_info["subtype_within_category"], ) function_info = FunctionInfo.create( single_fn=_geolocation, description=config.__doc__, input_schema=GeolocationInput, single_output_schema=GeolocationOutput, ) yield function_info ================================================ FILE: agent/src/vss_agents/tools/incidents.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import ast from collections.abc import AsyncGenerator import json import logging from typing import Any from typing import ClassVar import boto3 from dateutil import parser as dateutil_parser import duckdb from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class VARetrievalConfig(FunctionBaseConfig, name="va_retrieval"): """Configuration for the List Incidents tool.""" minio_url: str = Field( "http://localhost:9000", description="The endpoint URL of the MinIO/S3 server", ) access_key: str = Field( "minioadmin", description="The access key of the S3 bucket", ) secret_key: str = Field( "minioadmin", description="The secret key of the S3 bucket", ) bucket_name: str = Field( "incidents-bucket", description="The name of the S3 bucket containing incident data", ) prefix: str = Field( "", description="The prefix/folder path in the bucket to search for incident files", ) db_path: str = Field( ":memory:", description="DuckDB database path (':memory:' for in-memory, or file path for persistence)", ) file_extensions: list[str] = Field( [".json", ".ndjson"], description="List of file extensions to load as incident data", ) auto_refresh: bool = Field( False, description="Whether to automatically refresh data from bucket on each query", ) class VARetrievalInput(BaseModel): """Input for va_retrieval tool that supports SQL queries, high-level incident retrieval, and single incident lookup.""" # SQL query mode action: str | None = Field( None, description="The action to perform: 'get_schema' or 'query'. Use for direct SQL access.", ) sql_query: str | None = Field( None, description="The SQL query to perform (required if action='query')", ) # Single-incident retrieval mode (get_incident) id: str | None = Field( default=None, description="Specific incident ID to retrieve (mirrors video_analytics.get_incident 'id' field).", ) # High-level incident retrieval mode start_time: str | None = Field(None, description="Start time in ISO format (e.g., 2025-11-13T16:00:00.000Z)") end_time: str | None = Field(None, description="End time in ISO format (e.g., 2025-11-13T17:00:00.000Z)") source: str | None = Field(None, description="Source ID (e.g., sensor ID or place ID)") source_type: str | None = Field(None, description="Source type: 'sensor' or 'place'") max_count: int = Field(10, description="Maximum number of incidents to return") includes: list[str] | None = Field(None, description="Additional fields to include (e.g., objectIds, info)") class DuckDBIncidentsManager: """Manager class for DuckDB-based incident storage and querying.""" # Class-level storage for singleton instances _instances: ClassVar[dict[Any, "DuckDBIncidentsManager"]] = {} _locks: ClassVar[dict[Any, Any]] = {} def __init__(self, config: "VARetrievalConfig") -> None: self.config = config self._initialized = False self.s3_client: Any = None self.conn: Any = None @staticmethod def normalize_timestamp(timestamp: str | None) -> str | None: """ Normalize timestamp to DuckDB-compatible format. DuckDB handles ISO 8601 timestamps well, but we ensure consistency: - Converts 'Z' suffix to explicit '+00:00' timezone - Adds UTC timezone if missing - Validates timestamp format Args: timestamp: ISO format timestamp string or None Returns: Normalized timestamp string or None """ if not timestamp or not isinstance(timestamp, str): return timestamp try: # Handle common timestamp formats if timestamp.endswith("Z"): # Convert Zulu time to explicit UTC offset return timestamp[:-1] + "+00:00" elif "T" in timestamp and ("+" not in timestamp and "-" not in timestamp.split("T")[1]): # Add UTC timezone if missing (no offset after the time part) return timestamp + "+00:00" else: # Already has timezone info or is in acceptable format return timestamp except Exception as e: logger.warning(f"Failed to normalize timestamp {timestamp}: {e}") return timestamp @classmethod async def get_instance(cls, config: VARetrievalConfig) -> "DuckDBIncidentsManager": """Get or create a singleton instance for the given configuration.""" # Create a unique key based on config values config_key = (config.minio_url, config.access_key, config.bucket_name, config.prefix, config.db_path) # Ensure we have a lock for this config if config_key not in cls._locks: import asyncio cls._locks[config_key] = asyncio.Lock() # Use the lock to ensure thread-safe singleton creation async with cls._locks[config_key]: if config_key not in cls._instances: # Create new instance instance = cls(config) await instance._async_init() cls._instances[config_key] = instance logger.info(f"Created new DuckDBIncidentsManager instance for {config.bucket_name}/{config.prefix}") else: logger.info( f"Reusing existing DuckDBIncidentsManager instance for {config.bucket_name}/{config.prefix}" ) return cls._instances[config_key] async def _async_init(self) -> None: """Asynchronously initialize the manager.""" if self._initialized: return # Initialize S3 client self.s3_client = boto3.client( "s3", endpoint_url=self.config.minio_url, aws_access_key_id=self.config.access_key, aws_secret_access_key=self.config.secret_key, region_name="us-east-1", verify=True, ) # Initialize DuckDB connection self.conn = duckdb.connect(self.config.db_path) self._setup_database() # Load initial data await self.load_incidents_from_bucket() self._initialized = True @classmethod def clear_instances(cls) -> None: """Clear all singleton instances. Useful for testing or forced refresh.""" cls._instances.clear() logger.info("Cleared all DuckDBIncidentsManager singleton instances") async def refresh_data(self) -> None: """Manually refresh data from S3 bucket.""" if not self._initialized: await self._async_init() else: logger.info("Manually refreshing incidents data from S3") await self.load_incidents_from_bucket() def _setup_database(self) -> None: """Set up the database schema and indexes.""" # Create incidents table schema self.conn.execute(""" CREATE TABLE IF NOT EXISTS incidents ( Id VARCHAR PRIMARY KEY, sensorId VARCHAR NOT NULL, timestamp TIMESTAMP NOT NULL, end_timestamp TIMESTAMP, category VARCHAR, isAnomaly BOOLEAN DEFAULT FALSE, place JSON, analyticsModule JSON, info JSON, objectIds JSON, frameIds JSON, type VARCHAR DEFAULT 'mdx-incidents', -- Additional metadata source_file VARCHAR, loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # Create indexes for performance self.conn.execute("CREATE INDEX IF NOT EXISTS idx_sensor_timestamp ON incidents(sensorId, timestamp DESC)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_category ON incidents(category)") # Create metadata table to track loaded files self.conn.execute(""" CREATE TABLE IF NOT EXISTS loaded_files ( file_path VARCHAR PRIMARY KEY, file_size BIGINT, last_modified TIMESTAMP, loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, record_count INTEGER ) """) async def load_incidents_from_bucket(self) -> int: """Load all incident files from the configured S3 bucket.""" logger.info(f"Loading incidents from bucket: {self.config.bucket_name}/{self.config.prefix}") # List all objects in the bucket with the given prefix paginator = self.s3_client.get_paginator("list_objects_v2") pages = paginator.paginate(Bucket=self.config.bucket_name, Prefix=self.config.prefix) total_loaded = 0 for page in pages: if "Contents" not in page: continue for obj in page["Contents"]: key = obj["Key"] # Check if file extension matches if not any(key.lower().endswith(ext) for ext in self.config.file_extensions): continue # Check if file was already loaded existing = self.conn.execute( "SELECT last_modified FROM loaded_files WHERE file_path = ?", [key] ).fetchone() if existing: # Convert database timestamp to datetime for comparison db_last_modified = existing[0] if isinstance(db_last_modified, str): # Parse ISO format string db_last_modified = dateutil_parser.isoparse(db_last_modified) # Make S3 timestamp timezone-naive for comparison s3_last_modified = obj["LastModified"] if s3_last_modified.tzinfo: s3_last_modified = s3_last_modified.replace(tzinfo=None) if db_last_modified.tzinfo: db_last_modified = db_last_modified.replace(tzinfo=None) if db_last_modified >= s3_last_modified: logger.debug(f"Skipping already loaded file: {key}") continue try: # Download file content response = self.s3_client.get_object(Bucket=self.config.bucket_name, Key=key) content = response["Body"].read() # Load based on file type if key.lower().endswith(".json"): loaded = await self.load_json_content(content, key) else: logger.warning(f"Unsupported file type: {key}") continue # Update metadata # Store timestamp without timezone for consistency last_modified = obj["LastModified"] if last_modified.tzinfo: last_modified = last_modified.replace(tzinfo=None) self.conn.execute( """ INSERT OR REPLACE INTO loaded_files (file_path, file_size, last_modified, record_count) VALUES (?, ?, ?, ?) """, [key, obj["Size"], last_modified, loaded], ) total_loaded += loaded logger.info(f"Loaded {loaded} incidents from {key}") except Exception as e: logger.error(f"Error loading file {key}: {e}") continue logger.info(f"Total incidents loaded: {total_loaded}") return total_loaded async def load_json_content(self, content: bytes, source_file: str) -> int: """Load incidents from JSON content.""" data = json.loads(content) # Handle both single incident and array of incidents incidents = data if isinstance(data, list) else [data] if len(incidents) == 0: return 0 # Map JSON fields to database columns field_mapping = { "end": "end_timestamp", # Rename 'end' to 'end_timestamp' to match DB schema "start": "start_timestamp", # Map 'start' if present (SQL reserved keyword) } # Timestamp fields that need conversion timestamp_fields = {"timestamp", "end_timestamp", "start_timestamp", "end", "start"} # Process incidents to rename fields and handle timestamps processed_incidents = [] for incident in incidents: processed_incident = {} for key, value in incident.items(): # Use mapped column name if it exists, otherwise use original key column_name = field_mapping.get(key, key) # Convert timestamp strings to DuckDB-compatible format if key in timestamp_fields and value and isinstance(value, str): value = self.normalize_timestamp(value) processed_incident[column_name] = value processed_incidents.append(processed_incident) keys = [*processed_incidents[0].keys()] columns = ", ".join(keys) + ", source_file" placeholders = ", ".join(["?"] * len(keys)) + ", ?" insert_sql = f""" INSERT OR REPLACE INTO incidents ( {columns} ) VALUES ({placeholders}) """ count = 0 for incident in processed_incidents: try: self.conn.execute( insert_sql, [ *[incident.get(key) for key in keys], source_file, ], ) count += 1 except Exception as e: logger.error(f"Error inserting incident from {source_file}: {e}") return count def run_sql(self, sql: str) -> list[dict[str, Any]]: cursor = self.conn.execute(sql) columns = [desc[0] for desc in cursor.description] rows = cursor.fetchall() return [dict(zip(columns, row, strict=True)) for row in rows] def get_schema(self) -> list[tuple[Any, ...]]: result: list[tuple[Any, ...]] = self.conn.execute("DESCRIBE incidents").fetchall() return result @register_function(config_type=VARetrievalConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def va_retrieval(config: VARetrievalConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Query the video analytics incident database stored in DuckDB. Supports two modes: 1. SQL mode: Direct SQL queries (action='query' or 'get_schema') 2. High-level mode: Retrieve incidents by time range, sensor, etc. SQL Mode Input: action: 'get_schema' or 'query' sql_query: SQL query string (required if action='query') High-level Mode Input: start_time: ISO timestamp (e.g., "2025-11-13T16:00:00.000Z") end_time: ISO timestamp source: sensor ID or place ID (optional) source_type: 'sensor' or 'place' (optional) max_count: maximum incidents to return (default: 10) Returns: SQL mode: String representation of query results High-level mode: List of incident dictionaries with parsed JSON fields """ # Get or create singleton manager instance manager = await DuckDBIncidentsManager.get_instance(config) async def _va_retrieval(va_retrieval_input: VARetrievalInput) -> str | list[dict]: # Determine mode based on which fields are provided if va_retrieval_input.action is not None: # SQL mode if va_retrieval_input.action == "get_schema": return f"Table name: incidents\n\n Table schema: {manager.get_schema()}" elif va_retrieval_input.action == "query": if not va_retrieval_input.sql_query: raise ValueError("sql_query is required when action='query'") return str(manager.run_sql(va_retrieval_input.sql_query)) else: raise ValueError(f"Invalid action: {va_retrieval_input.action}") elif va_retrieval_input.id: # Single-incident retrieval mode incident_id = va_retrieval_input.id sql_query = """ SELECT Id, sensorId, CAST(timestamp AS VARCHAR) as timestamp, CAST(end_timestamp AS VARCHAR) as end, category, isAnomaly, place, analyticsModule, info, objectIds, frameIds, type, source_file FROM incidents WHERE Id = ? LIMIT 1 """ logger.info(f"Executing single-incident SQL query for Id={incident_id}") query_result = manager.conn.execute(sql_query, [incident_id]) columns = [desc[0] for desc in query_result.description] row = query_result.fetchone() if not row: logger.info(f"No incident found with Id={incident_id}") return json.dumps({}) incident = dict(zip(columns, row, strict=True)) # Parse JSON string fields back into objects json_fields = ["analyticsModule", "info", "objectIds", "frameIds", "place"] for field in json_fields: if field in incident and isinstance(incident[field], str): try: incident[field] = json.loads(incident[field]) except json.JSONDecodeError as e: logger.warning(f"Failed to parse JSON field '{field}' in incident {incident.get('Id')}: {e}") try: return json.dumps(incident) except TypeError: logger.exception("Failed to serialize single incident to JSON; falling back to string representation") return str(incident) elif va_retrieval_input.source or va_retrieval_input.start_time or va_retrieval_input.end_time: # High-level incident retrieval mode (time range and/or source filtering) # Build WHERE clause where_clauses = [] params = [] # Add time range filters if provided if va_retrieval_input.start_time: start_time = DuckDBIncidentsManager.normalize_timestamp(va_retrieval_input.start_time) where_clauses.append("timestamp >= ?") params.append(start_time) if va_retrieval_input.end_time: end_time = DuckDBIncidentsManager.normalize_timestamp(va_retrieval_input.end_time) where_clauses.append("timestamp <= ?") params.append(end_time) # Add source filters if provided if va_retrieval_input.source and va_retrieval_input.source_type: if va_retrieval_input.source_type.lower() == "sensor": where_clauses.append("sensorId = ?") params.append(va_retrieval_input.source) elif va_retrieval_input.source_type.lower() == "place": where_clauses.append("place::json->>'id' = ?") params.append(va_retrieval_input.source) # Ensure we have at least one filter if not where_clauses: raise ValueError("Must provide at least one filter: source information or time range") where_clause = " AND ".join(where_clauses) # Build SQL query with VARCHAR casting for timestamps sql_query = f""" SELECT Id, sensorId, CAST(timestamp AS VARCHAR) as timestamp, CAST(end_timestamp AS VARCHAR) as end, category, isAnomaly, place, analyticsModule, info, objectIds, frameIds, type, source_file FROM incidents WHERE {where_clause} ORDER BY timestamp DESC LIMIT {va_retrieval_input.max_count} """ logger.info(f"Executing SQL query: {sql_query}") query_result = manager.conn.execute(sql_query, params) columns = [desc[0] for desc in query_result.description] rows = query_result.fetchall() result_str = str([dict(zip(columns, row, strict=True)) for row in rows]) # Parse result and convert JSON strings to objects try: result = ast.literal_eval(result_str) if not isinstance(result, list): result = [result] if result else [] # Parse JSON string fields back into objects json_fields = ["analyticsModule", "info", "objectIds", "frameIds", "place"] for incident in result: for field in json_fields: if field in incident and isinstance(incident[field], str): try: incident[field] = json.loads(incident[field]) except json.JSONDecodeError as e: logger.warning( f"Failed to parse JSON field '{field}' in incident {incident.get('Id')}: {e}" ) logger.info(f"Retrieved {len(result)} incidents") try: return json.dumps({"incidents": result}) except TypeError: logger.exception("Failed to serialize incidents to JSON; falling back to string representation") return str({"incidents": result}) except (ValueError, SyntaxError): logger.exception("Failed to parse va_retrieval result") logger.error(f"Result string: {result_str}") return [] else: raise ValueError("Must provide either 'action' (SQL mode) or 'start_time'+'end_time' (high-level mode)") schema = "\n".join([f"{col[0]}: {col[1]}" for col in manager.get_schema()]) logger.info(f"Table schema: {schema}") yield FunctionInfo.create( single_fn=_va_retrieval, description=_va_retrieval.__doc__, input_schema=VARetrievalInput, single_output_schema=str | list, ) ================================================ FILE: agent/src/vss_agents/tools/lvs_video_understanding.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ LVS Video Understanding Tool with Mandatory HITL Prompt Configuration. This tool wraps the LVS (Long Video Summarization) service API to provide video understanding capabilities for long videos. It has a similar interface to the video_understanding tool but uses LVS's chunk-based processing. Key features: - Uses LVS service for hierarchical summarization (chunk-based processing) - Better suited for long videos (> 2 minutes) - MANDATORY Human-in-the-Loop (HITL) prompt configuration before every analysis - Prompts come from config and can be accepted or overridden by user during HITL - User must explicitly accept or modify all 3 prompts before video analysis begins """ from collections.abc import AsyncGenerator from enum import StrEnum import json import logging import aiohttp from nat.builder.builder import Builder from nat.builder.context import Context from nat.builder.context import ContextState from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from nat.data_models.interactive import HumanPromptText from nat.data_models.interactive import InteractionResponse from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field from vss_agents.utils.url_translation import translate_url logger = logging.getLogger(__name__) class LVSStatus(StrEnum): """Status values for LVS video understanding operations.""" ABORTED = "aborted" SUCCESS = "success" # Default HITL confirmation template DEFAULT_HITL_CONFIRMATION_TEMPLATE = """ Please review the above configuration that will be sent for video analysis: **Options:** • Press Submit (empty) → Confirm and proceed with video analysis • Type `/redo` → Modify parameters • Type `/cancel` → Cancel analysis Enter your choice or press Submit to proceed:""" class LVSVideoUnderstandingConfig(FunctionBaseConfig, name="lvs_video_understanding"): """Configuration for the LVS Video Understanding tool.""" lvs_backend_url: str = Field( ..., description="The URL of the LVS backend service (e.g., http://localhost:38111).", ) # Timeout configuration conn_timeout_ms: int = Field( default=5000, description="Connection timeout in milliseconds for LVS API calls.", ) read_timeout_ms: int = Field( default=600000, # 10 minutes for long videos description="Read timeout in milliseconds for LVS API calls.", ) model: str = Field( default="gpt-4o", description="LVS model to use for video analysis.", ) # Video URL tool for getting video URL from sensor ID video_url_tool: str = Field( default="vst_video_url", description="A tool to be used to get the video URL by sensor ID and timestamp (default to use VST service)", ) # API Parameters (configurable from config file) response_format_type: str = Field( default="text", description="Response format type (e.g., 'text', 'json')", ) enable_chat: bool = Field( default=False, description="Enable chat mode for LVS", ) enable_cv_metadata: bool = Field( default=False, description="Enable computer vision metadata in response", ) temperature: float = Field( default=0.4, description="Temperature for LLM sampling (0.0 to 1.0)", ) seed: int | None = Field( default=1, description="Random seed for reproducibility", ) top_p: float = Field( default=1.0, description="Top-p (nucleus) sampling parameter", ) top_k: int = Field( default=10, description="Top-k sampling parameter", ) max_tokens: int = Field( default=512, description="Maximum tokens in response", ) chunk_duration: int = Field( default=10, description="Duration of each video chunk in seconds (0 = entire video in one request)", ) num_frames_per_chunk: int = Field( default=20, description="Number of frames to sample per chunk", ) enable_audio: bool = Field( default=False, description="Enable audio processing", ) stream: bool = Field( default=True, description="Enable streaming response", ) include_usage: bool = Field( default=True, description="Include usage statistics in response", ) # HITL Templates (mandatory - configured in YAML) hitl_scenario_template: str = Field( ..., description="HITL template for collecting scenario from user", ) hitl_events_template: str = Field( ..., description="HITL template for collecting events from user", ) hitl_objects_template: str = Field( ..., description="HITL template for collecting objects_of_interest from user", ) hitl_confirmation_template: str | None = Field( default=None, description="HITL template for final confirmation before video analysis. If None, uses default template.", ) # Default values for HITL parameters default_scenario: str = Field( default="", description="Default scenario to use when no persistent state exists (e.g., 'traffic monitoring')", ) default_events: list[str] = Field( default_factory=list, description="Default events list to use when no persistent state exists (e.g., ['accident', 'pedestrian crossing'])", ) # URL translation configuration for VLM vlm_mode: str = Field( default="local", description="VLM mode: 'remote' (VLM is external, needs public URLs), 'local' or 'local_shared' (VLM is local, needs internal URLs)", ) internal_ip: str = Field( default="", description="Internal IP / docker host IP for URL translation", ) external_ip: str = Field( default="", description="Public IP accessible from the internet for URL translation", ) vst_internal_url: str | None = Field( default=None, description="Internal VST base URL (e.g., 'http://HOST_IP:30888'). " "Used for URL translation when behind a reverse proxy.", ) model_config = ConfigDict(extra="forbid") class LVSVideoUnderstandingInput(BaseModel): """Input for the LVS Video Understanding tool with mandatory HITL.""" sensor_id: str = Field( ..., description="The sensor ID of the video to understand.", min_length=1, ) @register_function(config_type=LVSVideoUnderstandingConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def lvs_video_understanding( config: LVSVideoUnderstandingConfig, builder: Builder ) -> AsyncGenerator[FunctionInfo]: """ LVS Video Understanding Tool with HITL for Scenario, Events, and Objects. This tool uses the LVS (Long Video Summarization) service to analyze videos and supports Human-in-the-Loop configuration of analysis parameters. HITL collects: - scenario (REQUIRED): Description of the video scenario - events (REQUIRED): List of events to detect - objects_of_interest (OPTIONAL): List of objects to focus on Parameters are persisted per conversation thread. """ logger.info(f"Initializing LVS Video Understanding tool (backend: {config.lvs_backend_url})") # Persistent state: maps thread_id -> (scenario, events, objects_of_interest) lvs_params_state: dict[str, tuple[str, list[str], list[str]]] = {} async def _prompt_user_input(prompt_text: str, required: bool = True, placeholder: str = "") -> str: """ Prompt user for input using HITL. Args: prompt_text: The prompt text to show to the user required: Whether the input is required placeholder: Placeholder text for the input Returns: str: User's input """ nat_context = Context.get() user_input_manager = nat_context.user_interaction_manager human_prompt = HumanPromptText(text=prompt_text, required=required, placeholder=placeholder) response: InteractionResponse = await user_input_manager.prompt_user_input(human_prompt) response_text: str = str(response.content.text).strip() return response_text def _format_lvs_config_summary( scenario: str, events: list[str], objects_of_interest: list[str], ) -> str: """ Format a summary of LVS configuration for user review. Args: scenario: The scenario description events: List of events to detect objects_of_interest: List of objects to focus on Returns: str: Formatted configuration summary """ summary_lines = [ "**Scenario:**", f"```\n{scenario}\n```", "", "**Events to Detect:**", f"```\n{', '.join(events)}\n```", "", ] if objects_of_interest: summary_lines.extend( [ "**Objects of Interest:**", f"```\n{', '.join(objects_of_interest)}\n```", ] ) else: summary_lines.extend( [ "**Objects of Interest:**", "```\nNone\n```", ] ) return "\n".join(summary_lines) async def _confirm_lvs_request( scenario: str, events: list[str], objects_of_interest: list[str], ) -> str: """ Show all LVS configuration and get user confirmation. Args: scenario: The scenario description events: List of events to detect objects_of_interest: List of objects to focus on Returns: str: Normalized user choice ("/redo", "/cancel", or empty string for proceed) """ config_summary = _format_lvs_config_summary(scenario, events, objects_of_interest) hitl_template = config.hitl_confirmation_template or DEFAULT_HITL_CONFIRMATION_TEMPLATE prompt_text = f"{config_summary}\n\n{hitl_template}" user_choice = await _prompt_user_input( prompt_text, required=False, placeholder="/redo, /cancel, or press Submit to proceed", ) # Return normalized choice return user_choice.lower().strip() async def _collect_hitl_parameters( current_params: tuple[str, list[str], list[str]] | None = None, ) -> tuple[str, list[str], list[str]] | None: """ Collect scenario, events, and objects_of_interest via HITL. If current_params is provided, shows current values and allows user to accept or modify. User can type /cancel at any step to abort. Args: current_params: Optional current parameters (scenario, events, objects_of_interest) Returns: tuple: (scenario, events, objects_of_interest), or None if cancelled """ logger.info("Starting HITL parameter collection workflow") # Cancel info to append to each prompt cancel_info = "\n\n**Note:** Type `/cancel` at any time to abort the video analysis." # Build prompt with current values if they exist if current_params: current_scenario, current_events, current_objects = current_params scenario_prompt = f"**CURRENTLY SET:** `{current_scenario}`\n\n{config.hitl_scenario_template}{cancel_info}" events_prompt = ( f"**CURRENTLY SET:** `{', '.join(current_events)}`\n\n{config.hitl_events_template}{cancel_info}" ) if current_objects: objects_prompt = ( f"**CURRENTLY SET:** `{', '.join(current_objects)}`\n\n{config.hitl_objects_template}{cancel_info}" ) else: objects_prompt = f"**CURRENTLY SET:** None\n\n{config.hitl_objects_template}{cancel_info}" else: # Use default values from config when no persistent state exists current_scenario = config.default_scenario current_events = config.default_events current_objects = [] # Always empty by default if current_scenario or current_events: # Show defaults if they exist if current_scenario: scenario_prompt = ( f"**DEFAULT:** `{current_scenario}`\n\n{config.hitl_scenario_template}{cancel_info}" ) else: scenario_prompt = f"{config.hitl_scenario_template}{cancel_info}" if current_events: events_prompt = ( f"**DEFAULT:** `{', '.join(current_events)}`\n\n{config.hitl_events_template}{cancel_info}" ) else: events_prompt = f"{config.hitl_events_template}{cancel_info}" objects_prompt = f"{config.hitl_objects_template}{cancel_info}" else: # No defaults configured scenario_prompt = f"{config.hitl_scenario_template}{cancel_info}" events_prompt = f"{config.hitl_events_template}{cancel_info}" objects_prompt = f"{config.hitl_objects_template}{cancel_info}" # Collect scenario (REQUIRED) scenario = "" while not scenario: user_input = await _prompt_user_input( scenario_prompt, required=not bool(current_scenario), # Not required if we have a current value placeholder="e.g., traffic monitoring or /cancel", ) # Check for /cancel if user_input and user_input.strip().lower() == "/cancel": logger.info("User cancelled during scenario collection") return None if not user_input and current_scenario: scenario = current_scenario logger.info(f"User accepted current scenario: {scenario}") elif user_input: scenario = user_input logger.info(f"User provided new scenario: {scenario}") else: logger.warning("Scenario is required, prompting again") # Collect events (REQUIRED) events: list[str] = [] while not events: user_input = await _prompt_user_input( events_prompt, required=not bool(current_events), # Not required if we have current values placeholder="e.g., accident, pedestrian crossing or /cancel", ) # Check for /cancel if user_input and user_input.strip().lower() == "/cancel": logger.info("User cancelled during events collection") return None # If user pressed Enter and we have current values, use them if not user_input and current_events: events = current_events logger.info(f"User accepted current events: {events}") elif user_input: events = [e.strip() for e in user_input.split(",") if e.strip()] logger.info(f"User provided new events: {events}") else: logger.warning("Events are required, prompting again") # Collect objects_of_interest (OPTIONAL - requires explicit "skip" to skip) user_input = await _prompt_user_input( objects_prompt, required=False, placeholder='e.g., cars, trucks, pedestrians OR type "skip" to skip or /cancel', ) # Check for /cancel if user_input and user_input.strip().lower() == "/cancel": logger.info("User cancelled during objects collection") return None # Check if user explicitly typed "skip" if user_input.lower() == "skip": objects_of_interest = [] logger.info("User explicitly skipped objects_of_interest") elif not user_input and current_objects: # Empty input with existing state -> keep current values objects_of_interest = current_objects logger.info(f"User accepted current objects_of_interest: {objects_of_interest}") elif user_input: # User provided new values objects_of_interest = [o.strip() for o in user_input.split(",") if o.strip()] logger.info(f"User provided new objects_of_interest: {objects_of_interest}") else: # Empty input with no existing state -> require explicit input logger.warning("Please provide objects_of_interest or type 'skip' to skip") objects_of_interest = [] logger.info("HITL parameter collection completed") return scenario, events, objects_of_interest async def _lvs_video_understanding(lvs_input: LVSVideoUnderstandingInput) -> str: """ Use LVS(Long Video Summarization) service to understand and summarize a video. This tool is optimized for long videos and uses chunk-based processing with event detection. Args: lvs_input: LVSVideoUnderstandingInput with sensor_id(sensor name or video file name in VST) Returns: str: The summary/analysis from LVS service """ # Get thread_id for state persistence thread_id = ContextState.get().conversation_id.get() logger.info(f"Processing LVS request for thread {thread_id}") logger.info(f"LVS Video Understanding: Processing '{lvs_input.sensor_id}'") # Get current parameters for this thread (if any) current_params = lvs_params_state.get(thread_id) if current_params: logger.info(f"Found existing parameters for thread {thread_id}") else: logger.info(f"No existing parameters for thread {thread_id}, will collect new ones") # Initialize variables for type checker scenario: str = "" events: list[str] = [] objects_of_interest: list[str] = [] # HITL workflow with confirmation loop while True: # Step 1: Collect parameters via HITL logger.info("Running HITL workflow to collect/confirm parameters") params_result = await _collect_hitl_parameters(current_params) # Handle cancellation if params_result is None: logger.info("LVS analysis cancelled by user during parameter collection") return json.dumps( { "status": LVSStatus.ABORTED.value, "message": "Video analysis was cancelled by user.", }, indent=2, ) scenario, events, objects_of_interest = params_result # Step 2: Show all configs and get confirmation (before fetching video URL) logger.info("Showing LVS configuration for user confirmation") user_choice = await _confirm_lvs_request(scenario, events, objects_of_interest) if user_choice == "/redo": # User wants to modify parameters - loop back with current values logger.info("User requested redo - restarting parameter collection") current_params = (scenario, events, objects_of_interest) continue elif user_choice == "/cancel": # User cancelled logger.info("LVS analysis cancelled by user") return json.dumps( { "status": LVSStatus.ABORTED.value, "message": "Video analysis was cancelled by user.", }, indent=2, ) else: # Empty string or any other input - proceed with LVS request logger.info("User confirmed - proceeding with LVS analysis") break # Store HITL parameters for later inclusion in the report hitl_scenario = scenario hitl_events = events hitl_objects_of_interest = objects_of_interest # Update state for this thread lvs_params_state[thread_id] = (scenario, events, objects_of_interest) logger.info(f"Updated parameters state for thread {thread_id}") # Load video URL tool (deferred to runtime to avoid initialization order issues) logger.info(f"Loading video URL tool: {config.video_url_tool}") video_url_tool = await builder.get_tool(config.video_url_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) # Get video URL using the video_url_tool (e.g., vst_video_url) logger.info(f"Using {config.video_url_tool} to get video URL for file {lvs_input.sensor_id}") video_url_args = { "sensor_id": lvs_input.sensor_id, } logger.debug(f"Video URL tool arguments: {video_url_args}") video_url_result = await video_url_tool.ainvoke(input=video_url_args) video_url = video_url_result.video_url # Translate URL for VLM based on vlm_mode: # - remote: INTERNAL_IP -> EXTERNAL_IP (VLM needs public URLs) # - local/local_shared: EXTERNAL_IP -> INTERNAL_IP (VLM needs internal URLs) video_url = translate_url( video_url, config.vlm_mode, config.internal_ip, config.external_ip, config.vst_internal_url, ) logger.info(f"[LVS Video Understanding] VIDEO URL FOR VLM ANALYSIS: {video_url}") # Build LVS request using new API contract lvs_request = { "url": video_url, "model": config.model, # HITL parameters "scenario": scenario, "events": events, # Video processing parameters "chunk_duration": config.chunk_duration, "num_frames_per_chunk": config.num_frames_per_chunk, } logger.info(f"LVS request: {lvs_request}") # Add seed if configured if config.seed is not None: lvs_request["seed"] = config.seed if objects_of_interest: lvs_request["objects_of_interest"] = objects_of_interest logger.info(f"Calling LVS service: {config.lvs_backend_url}/summarize") logger.debug(f"LVS request: {lvs_request}") # Call LVS service try: timeout = aiohttp.ClientTimeout(connect=config.conn_timeout_ms / 1000, total=config.read_timeout_ms / 1000) async with ( aiohttp.ClientSession(timeout=timeout) as session, session.post(f"{config.lvs_backend_url}/summarize", json=lvs_request) as response, ): if response.status != 200: error_text = await response.text() raise RuntimeError(f"LVS service returned {response.status}: {error_text}") response_json = await response.json() # Parse OpenAI-style response format: choices[0].message.content contains JSON string content = response_json["choices"][0]["message"]["content"] content_json = json.loads(content) logger.info(f"LVS response: {content_json}") video_summary = content_json.get("video_summary", "").strip() events = content_json.get("events", []) # Generate friendly message only if no summary and no events if not video_summary and not events: video_summary = "No significant events or activities were detected in this video." logger.warning("LVS returned no summary and no events") elif not video_summary: logger.info(f"LVS returned no summary but has {len(events)} events") if not events: logger.warning("LVS returned no events") result = { "video_summary": video_summary, "events": events, "hitl_prompts": { "scenario": hitl_scenario, "events": hitl_events, "objects_of_interest": hitl_objects_of_interest, }, "lvs_backend_response": response_json, } if not video_summary and not events: result["note"] = ( "The video may not contain the types of events specified in the search criteria, or the content may not be clear enough for detection." ) formatted_response = json.dumps(result, indent=2, ensure_ascii=False) logger.info(f"LVS response received with {len(events)} events") return formatted_response except aiohttp.ClientError as e: logger.error(f"LVS service connection error: {e}") raise RuntimeError(f"Failed to connect to LVS service: {e}") from e except Exception as e: logger.error(f"LVS video understanding failed: {e}") raise yield FunctionInfo.create( single_fn=_lvs_video_understanding, description=_lvs_video_understanding.__doc__, input_schema=LVSVideoUnderstandingInput, single_output_schema=str, ) ================================================ FILE: agent/src/vss_agents/tools/multi_incident_formatter.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio from collections import Counter from collections import defaultdict from collections.abc import AsyncGenerator from datetime import datetime from datetime import timedelta import json import logging from typing import Any from typing import Literal from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from pydantic import field_validator logger = logging.getLogger(__name__) def _normalize_timestamp(timestamp: str) -> str: """ Normalize timestamp to ISO 8601 format with exactly 3 digits for milliseconds. Handles timestamps with microseconds (6 digits) and truncates to milliseconds (3 digits). Args: timestamp: ISO timestamp string (e.g., '2025-11-17T15:16:38.273512Z' or '2025-11-17T15:16:38.273Z') Returns: Normalized timestamp with 3 decimal places (e.g., '2025-11-17T15:16:38.273Z') """ # If timestamp has more than 3 decimal places, truncate to 3 if "." in timestamp: date_part, rest = timestamp.split(".", 1) fractional_part = rest.rstrip("Z") # Truncate to 3 digits (milliseconds) or pad with zeros if less than 3 fractional_part = fractional_part[:3].ljust(3, "0") return f"{date_part}.{fractional_part}Z" return timestamp class MultiIncidentFormatterConfig(FunctionBaseConfig, name="multi_incident_formatter"): """Configuration for the multi-incident formatter tool.""" video_url_tool: FunctionRef = Field( ..., description="The tool to use for getting video URLs", ) picture_url_tool: FunctionRef = Field( ..., description="The tool to use for getting picture URLs", ) incidents_tool: FunctionRef = Field( ..., description="The tool to use for getting incidents within a time range", ) chart_generator_tool: FunctionRef | None = Field( default=None, description="The tool to use for generating charts (optional)", ) generate_chart: bool = Field( default=False, description="Whether to automatically generate a chart visualizing the incidents.", ) chart_base_url: str = Field( default="http://localhost:38000/reports/", description="Base URL for accessing stored chart images", ) display_limit: int = Field( default=20, gt=0, le=100, description="Maximum number of incidents to format and display in UI with full details (video/snapshot URLs). " "Charts will show all fetched incidents regardless of this limit.", ) class IncidentData(BaseModel): """Single incident data.""" incident_id: str = Field(..., description="Unique identifier for the incident") sensor_id: str = Field(..., description="Sensor ID where the incident occurred") start_timestamp: str = Field(..., description="Start timestamp in ISO format") end_timestamp: str = Field(..., description="End timestamp in ISO format") metadata: dict = Field(default_factory=dict, description="Additional incident metadata") class MultiIncidentFormatterInput(BaseModel): """Input for the multi-incident formatter tool. Fetches incidents within a specified time range for a given source. """ source: str = Field(..., description="Source to fetch incidents from (e.g., sensor ID or place)") source_type: Literal["sensor", "place"] = Field(..., description="Type of the source (must be 'sensor' or 'place')") start_time: str | None = Field( default=None, description="Optional start time in ISO format (e.g., '2025-09-22T14:00:00.000Z'). If omitted, fetches most recent incidents.", ) end_time: str | None = Field( default=None, description="Optional end time in ISO format (e.g., '2025-09-22T15:00:00.000Z'). If omitted, fetches most recent incidents.", ) max_result_size: int = Field( default=10000, description="Maximum number of incidents to fetch. " "Default is 10000 to get all incidents. " "Note: UI will display only top incidents, but charts will show all fetched incidents.", gt=0, le=10000, ) @field_validator("start_time", "end_time") @classmethod def normalize_timestamps(cls, v: str | None) -> str | None: """Normalize timestamp to ISO 8601 format with exactly 3 digits for milliseconds.""" if v is None: return None return _normalize_timestamp(v) class MultiIncidentFormatterOutput(BaseModel): """Output from the multi-incident formatter tool.""" formatted_incidents: str = Field( ..., description="Formatted string containing all incidents", ) total_incidents: int = Field( ..., description="Total number of incidents processed", ) chart_html: str | None = Field( default=None, description="HTML img tag for the generated chart (if generate_chart was True)", ) async def _fetch_incidents( formatter_input: MultiIncidentFormatterInput, incidents_tool: Any, ) -> list[IncidentData]: """Fetch incidents using the incidents tool.""" logger.info( f"Fetching incidents for {formatter_input.source_type} {formatter_input.source} " f"(max {formatter_input.max_result_size} results)" ) tool_input = { "source": formatter_input.source, "source_type": formatter_input.source_type, "start_time": formatter_input.start_time, "end_time": formatter_input.end_time, "max_count": formatter_input.max_result_size, "includes": ["object_ids", "info", "category"], } result = await incidents_tool.ainvoke(input=tool_input) # Parse the result into IncidentData objects incidents: list[IncidentData] = [] if isinstance(result, str): try: result = json.loads(result) except json.JSONDecodeError as e: logger.error(f"Failed to parse JSON string: {e}") return incidents if isinstance(result, dict) and "incidents" in result: raw_incidents = result["incidents"] else: logger.error(f"Unexpected result format after parsing: {type(result)}") return incidents for incident in raw_incidents: if not isinstance(incident, dict): logger.warning(f"Skipping non-dict incident: {type(incident)}") continue incident_id = incident.get("Id", "unknown") sensor_id = incident.get("sensorId", formatter_input.source) start_timestamp = incident.get("timestamp", "") end_timestamp = incident.get("end", "") metadata = { "category": incident.get("category"), "type": incident.get("type"), "objectIds": incident.get("objectIds", []), "info": incident.get("info", {}), "place": incident.get("place", {}), "isAnomaly": incident.get("isAnomaly", False), "analyticsModule": incident.get("analyticsModule", {}), "frameIds": incident.get("frameIds", []), } incidents.append( IncidentData( incident_id=incident_id, sensor_id=sensor_id, start_timestamp=start_timestamp, end_timestamp=end_timestamp, metadata=metadata, ) ) logger.info(f"Fetched {len(incidents)} incidents") return incidents async def _format_single_incident( incident: IncidentData, video_url_tool: Any, picture_url_tool: Any, incident_number: int, ) -> dict: """Format a single incident as a JSON object with video and image URLs.""" try: logger.info(f"Processing incident {incident.incident_id}") # Get video URL video_url_result = await video_url_tool.ainvoke( input={ "sensor_id": incident.sensor_id, "start_time": incident.start_timestamp, "end_time": incident.end_timestamp, } ) video_url = video_url_result.video_url if hasattr(video_url_result, "video_url") else str(video_url_result) # Get picture URL picture_url_result = await picture_url_tool.ainvoke( input={ "sensor_id": incident.sensor_id, "start_time": incident.start_timestamp, } ) snapshot_url = ( picture_url_result.image_url if hasattr(picture_url_result, "image_url") else str(picture_url_result) ) clip_info = { "Timestamp": incident.start_timestamp, "Stream": incident.sensor_id, "snapshot_url": snapshot_url, "video_url": video_url, } alert_details = { "Incident ID": incident.incident_id, "Alert Category": incident.metadata.get("category", "Unknown Alert"), } info = incident.metadata.get("info", {}) verification_code = info.get("verificationResponseCode", info.get("verification_response_code")) # Only use verification fields if verification code is 200 if verification_code == "200" or verification_code == 200: verification_status = info.get("verificationResponseStatus", info.get("verification_response_status")) reasoning = info.get("reasoning") verdict = info.get("verdict") if verification_status: alert_details["Verification Status"] = verification_status if reasoning: alert_details["Reasoning"] = reasoning if verdict: alert_details["Verdict"] = verdict # Build the JSON structure incident_json = { "Alert Title": f"Alert Triggered {incident_number}", "Clip Information": clip_info, "Alert Details": alert_details, } return incident_json except Exception as e: logger.error(f"Error formatting incident {incident.incident_id}: {e}") # Return a basic error structure return { "Alert Title": f"Alert Triggered {incident_number}", "Clip Information": { "Timestamp": incident.start_timestamp, "Stream": incident.sensor_id, "snapshot_url": "Error", "video_url": "Error", }, "Alert Details": { "Incident ID": incident.incident_id, "Alert Triggered": "Error", "Validation": False, "Alert Description": f"Failed to retrieve full details - {e!s}", }, } async def _generate_incidents_chart( incidents: list[IncidentData], chart_generator_tool: Any, chart_base_url: str | None ) -> str: """Generate a chart visualization of incident categories distribution. Args: incidents: List of incident data chart_generator_tool: The chart generator tool (LangChain wrapped) chart_base_url: Base URL for chart images Returns: HTML string with img tag for the chart """ # Get category from metadata, default to "Unknown" if missing incident_categories = [inc.metadata.get("category") or "Unknown" for inc in incidents] category_counts = Counter(incident_categories) # Filter out empty string keys (keep "Unknown") valid_categories = {k: v for k, v in category_counts.items() if k and str(k).strip()} if not valid_categories: valid_categories = {"Unknown": len(incidents)} chart_input = { "charts_data": [ { "sizes": list(valid_categories.values()), "labels": list(valid_categories.keys()), "title": "Incidents by Type", "chart_file_format": "png", } ], "output_dir": "incident_charts", "file_prefix": "incidents_", } result = await chart_generator_tool.ainvoke(input=chart_input) # The result is a list of ChartGenExecOutput - manually convert to HTML if isinstance(result, list): output_html = "" for chart in result: if chart.success and chart.object_store_key and chart_base_url: output_html += f'Incident Chart' return output_html else: return str(result) def _determine_optimal_bin_size(incidents: list[IncidentData]) -> str | None: """Automatically determine the optimal bin size based on incident count, timestamp range, and density. Strategy: - Aims for 20-50 bins for optimal visualization - Considers both time range and incident density - Adjusts based on total incident count to prevent over-binning Args: incidents: List of incident data Returns: Optimal bin size string ('1min', '10min', '1hr', '1day') or None if no valid timestamps """ if not incidents: return None timestamps = [] for inc in incidents: try: timestamp = datetime.fromisoformat(inc.start_timestamp.replace("Z", "+00:00")) timestamps.append(timestamp) except Exception as e: logger.warning(f"Failed to parse timestamp {inc.start_timestamp}: {e}") continue if len(timestamps) < 2: return "10min" min_time = min(timestamps) max_time = max(timestamps) time_range = max_time - min_time total_seconds = time_range.total_seconds() total_minutes = total_seconds / 60 total_hours = total_minutes / 60 total_days = total_hours / 24 # Target 30 bins for optimal visualization (acceptable range: 25-35) target_bins = 30 min_bins = 25 max_bins = 35 # Calculate what each bin size would give us bins_1min = total_minutes bins_10min = total_minutes / 10 bins_1hr = total_hours bins_1day = total_days logger.debug(f"Time range: {total_days:.2f} days, {total_hours:.2f} hours, {total_minutes:.2f} minutes") logger.debug( f"Potential bins - 1min: {bins_1min:.0f}, 10min: {bins_10min:.0f}, 1hr: {bins_1hr:.0f}, 1day: {bins_1day:.0f}" ) # Choose bin size closest to target_bins, within acceptable range bin_options = [ ("1day", bins_1day), ("1hr", bins_1hr), ("10min", bins_10min), ("1min", bins_1min), ] # Filter options that fall within acceptable range [min_bins, max_bins] valid_options = [(size, count) for size, count in bin_options if min_bins <= count <= max_bins] if valid_options: # Choose the option closest to target_bins within the acceptable range best_option = min(valid_options, key=lambda x: abs(x[1] - target_bins)) logger.debug(f"Selected bin size: {best_option[0]} ({best_option[1]:.0f} bins)") return best_option[0] # If no options fall within range, choose the closest option to the range # Prefer options just below min_bins over those above max_bins below_min = [(size, count) for size, count in bin_options if count < min_bins and count > 0] above_max = [(size, count) for size, count in bin_options if count > max_bins] if below_min: # Choose the one with most bins (closest to min_bins) best_option = max(below_min, key=lambda x: x[1]) elif above_max: # Choose the one with fewest bins (closest to max_bins) best_option = min(above_max, key=lambda x: x[1]) else: # Fallback to any non-zero option best_option = max(bin_options, key=lambda x: x[1] if x[1] > 0 else 0) logger.debug(f"Selected bin size: {best_option[0]} ({best_option[1]:.0f} bins) - outside target range") return best_option[0] async def _generate_time_series_chart( incidents: list[IncidentData], chart_generator_tool: Any, chart_base_url: str | None, bin_size: str, ) -> str: """Generate a time-series bar chart showing incident count over time. Args: incidents: List of incident data chart_generator_tool: The chart generator tool (LangChain wrapped) chart_base_url: Base URL for chart images bin_size: Time bin size - '1min', '10min', '1hr', or '1day' Returns: HTML string with img tag for the chart """ # Map bin_size to timedelta bin_deltas = { "1min": timedelta(minutes=1), "10min": timedelta(minutes=10), "1hr": timedelta(hours=1), "1day": timedelta(days=1), } if bin_size not in bin_deltas: logger.error(f"Invalid bin_size: {bin_size}. Must be one of {list(bin_deltas.keys())}") return "" # Parse timestamps and bin them binned_counts: defaultdict[datetime, int] = defaultdict(int) for inc in incidents: try: timestamp = datetime.fromisoformat(inc.start_timestamp.replace("Z", "+00:00")) # Round down to the nearest bin bin_start = timestamp.replace(second=0, microsecond=0) if bin_size == "1min": pass # Already at minute precision elif bin_size == "10min": bin_start = bin_start.replace(minute=(bin_start.minute // 10) * 10) elif bin_size == "1hr": bin_start = bin_start.replace(minute=0) elif bin_size == "1day": bin_start = bin_start.replace(hour=0, minute=0) binned_counts[bin_start] += 1 except Exception as e: logger.warning(f"Failed to parse timestamp {inc.start_timestamp}: {e}") continue if not binned_counts: logger.warning("No valid timestamps found for time-series chart") return "" # Sort bins chronologically sorted_bins = sorted(binned_counts.keys()) # Format labels based on bin size if bin_size == "1day": labels = [bin_time.strftime("%Y-%m-%d") for bin_time in sorted_bins] elif bin_size in ["1hr", "10min"]: labels = [bin_time.strftime("%m-%d %H:%M") for bin_time in sorted_bins] else: # 1min labels = [bin_time.strftime("%H:%M:%S") for bin_time in sorted_bins] counts = [binned_counts[bin_time] for bin_time in sorted_bins] # Create bar chart input chart_input = { "charts_data": [ { "x_categories": labels, "series": {"Incidents": counts}, "x_label": "Time", "y_label": "Incident Count", "title": f"Incidents Over Time ({bin_size} bins)", "chart_file_format": "png", } ], "output_dir": "incident_charts", "file_prefix": "incidents_timeseries_", } result = await chart_generator_tool.ainvoke(input=chart_input) # Convert result to HTML if isinstance(result, list): output_html = "" for chart in result: if chart.success and chart.object_store_key and chart_base_url: output_html += f'Time Series Chart' return output_html else: return str(result) async def _multi_incident_formatter_impl( formatter_input: MultiIncidentFormatterInput, video_url_tool: Any, picture_url_tool: Any, incidents_tool: Any, chart_generator_tool: Any | None = None, generate_chart: bool = False, chart_base_url: str | None = None, display_limit: int = 20, ) -> MultiIncidentFormatterOutput: """ Fetch and format multiple incidents in parallel. This tool fetches incidents from a sensor and formats each one by: 1. Fetching ALL incidents for chart data 2. Displaying only top 20 incidents with video/snapshot URLs 3. Generating charts based on ALL incidents for accurate visualization 4. Using improved bin size calculation based on total incident count Input: source: Source to fetch incidents from (sensor ID, place) source_type: Type of the source start_time: Optional start time in ISO format end_time: Optional end time in ISO format max_result_size: Maximum number of incidents to fetch (default: 10000, max: 10000) Returns: MultiIncidentFormatterOutput: Top N formatted incidents as JSON string with tags and chart based on ALL incidents """ try: incidents = await _fetch_incidents(formatter_input, incidents_tool) if not incidents: empty_output = '\n\n{\n "incidents": []\n}\n' return MultiIncidentFormatterOutput( formatted_incidents=empty_output, total_incidents=0, chart_html=None, ) logger.info(f"Fetched {len(incidents)} total incidents. Will display top {display_limit} in UI.") # Step 2: Take only top N incidents for formatting (display_limit from input) incidents_to_format = incidents[:display_limit] logger.info(f"Formatting {len(incidents_to_format)} incidents in parallel") # Step 3: Format selected incidents in parallel tasks = [ _format_single_incident( incident, video_url_tool, picture_url_tool, incident_number=i + 1, ) for i, incident in enumerate(incidents_to_format) ] formatted_results = await asyncio.gather(*tasks) # Step 4: Build the final JSON structure with tags incidents_json = { "incidents": formatted_results, "total_incidents": len(incidents), "displayed_incidents": len(formatted_results), } json_string = json.dumps(incidents_json, indent=2) formatted_output = f"\n\n{json_string}\n" # Step 5: Generate charts based on ALL fetched incidents (not just displayed 20) chart_html = None all_charts_html = [] # Generate pie chart if generate_chart flag is True if generate_chart and chart_generator_tool: try: pie_chart = await _generate_incidents_chart(incidents, chart_generator_tool, chart_base_url) if pie_chart: all_charts_html.append(pie_chart) logger.info(f"Successfully generated incidents pie chart from {len(incidents)} incidents") except Exception as e: logger.error(f"Failed to generate pie chart: {e}", exc_info=True) # Generate time-series bar chart with improved bin size calculation if generate_chart and chart_generator_tool: try: # Determine optimal bin size based on ALL fetched incidents bin_size = _determine_optimal_bin_size(incidents) logger.info(f"Auto-determined optimal bin size: {bin_size} based on {len(incidents)} incidents") if bin_size: time_series_chart = await _generate_time_series_chart( incidents, chart_generator_tool, chart_base_url, bin_size ) if time_series_chart: all_charts_html.append(time_series_chart) logger.info(f"Successfully generated time-series chart with bin size {bin_size}") except Exception as e: logger.error(f"Failed to generate time-series chart: {e}", exc_info=True) if all_charts_html: chart_html = "\n".join(all_charts_html) return MultiIncidentFormatterOutput( formatted_incidents=formatted_output, total_incidents=len(incidents), chart_html=chart_html, ) except Exception as e: logger.error(f"Error in multi-incident formatter: {e}") raise @register_function(config_type=MultiIncidentFormatterConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def multi_incident_formatter( config: MultiIncidentFormatterConfig, builder: Builder ) -> AsyncGenerator[FunctionInfo]: video_url_tool = await builder.get_tool(config.video_url_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) picture_url_tool = await builder.get_tool(config.picture_url_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) incidents_tool = await builder.get_tool(config.incidents_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) chart_generator_tool = None if config.chart_generator_tool: chart_generator_tool = await builder.get_tool( config.chart_generator_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN ) async def _multi_incident_formatter(formatter_input: MultiIncidentFormatterInput) -> MultiIncidentFormatterOutput: return await _multi_incident_formatter_impl( formatter_input, video_url_tool, picture_url_tool, incidents_tool, chart_generator_tool, config.generate_chart, config.chart_base_url, config.display_limit, ) yield FunctionInfo.create( single_fn=_multi_incident_formatter, description=_multi_incident_formatter_impl.__doc__, input_schema=MultiIncidentFormatterInput, single_output_schema=MultiIncidentFormatterOutput, ) ================================================ FILE: agent/src/vss_agents/tools/prompt_gen.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging from langchain_core.prompts import ChatPromptTemplate from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.prompt import VSS_SUMMARIZE_PROMPT logger = logging.getLogger(__name__) class PromptGenConfig(FunctionBaseConfig, name="prompt_gen"): """Configuration for the Prompt Gen tool.""" llm_name: str = Field(..., description="The name of the LLM to use") prompt: str = Field(default=VSS_SUMMARIZE_PROMPT, description="The prompt to generate the summarize prompt") class PromptGenInput(BaseModel): """Input for the Prompt Gen tool.""" user_query: str = Field(..., description="The user's query") user_intent: str = Field(..., description="The user's intent") detailed_thinking: bool = Field(default=False, description="Whether to include detailed thinking in the prompt") previous_prompt: str = Field(default="", description="The previous prompt to use to generate the new prompt") @register_function(config_type=PromptGenConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def prompt_gen(config: PromptGenConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """Generate a prompt for the user's query.""" async def _prompt_gen(prompt_gen_input: PromptGenInput) -> str: llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) messages = [] if prompt_gen_input.detailed_thinking: messages.append(("system", "detailed thinking on")) messages.append(("system", config.prompt)) messages.append(("user", "Please generate the prompts now.")) qa_chain_prompt = ChatPromptTemplate.from_messages(messages=messages) qa_chain = qa_chain_prompt | llm result = await qa_chain.ainvoke( {"user_query": prompt_gen_input.user_query, "user_intent": prompt_gen_input.user_intent} ) result = result.content if prompt_gen_input.previous_prompt: merge_quesion_prompt = ChatPromptTemplate.from_messages( [ ( "system", "merge the following prompts into one prompt, remove duplicates, make the prompt concise, clear and cover all instructions. ONLY return the merged prompt, do not include any other text.", ), ("user", "previous prompt: {previous_prompt}"), ("user", "new prompt: {new_prompt}"), ] ) merge_quesion_chain = merge_quesion_prompt | llm result = await merge_quesion_chain.ainvoke( { "previous_prompt": prompt_gen_input.previous_prompt, "new_prompt": result, } ) result = result.content return str(result) yield FunctionInfo.create( single_fn=_prompt_gen, description=_prompt_gen.__doc__, input_schema=PromptGenInput, single_output_schema=str, ) ================================================ FILE: agent/src/vss_agents/tools/register.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from . import attribute_search from . import chart_generator from . import embed_search from . import fov_counts_with_chart from . import geolocation from . import incidents from . import lvs_video_understanding from . import multi_incident_formatter from . import prompt_gen from . import report_gen from . import rtvi_vlm_alert from . import s3_picture_url from . import search from . import template_report_gen from . import video_caption from . import video_report_gen from . import video_understanding from . import vss_summarize from .code_executor.python_executor import python_executor __all__ = [ "attribute_search", "chart_generator", "embed_search", "fov_counts_with_chart", "geolocation", "incidents", "lvs_video_understanding", "multi_incident_formatter", "prompt_gen", "python_executor", "report_gen", "rtvi_vlm_alert", "s3_picture_url", "search", "template_report_gen", "video_caption", "video_report_gen", "video_understanding", "vss_summarize", ] ================================================ FILE: agent/src/vss_agents/tools/report_gen.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator from datetime import datetime import logging import os from pathlib import Path from typing import Any from langchain_core.prompts import ChatPromptTemplate from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import ObjectStoreRef from nat.data_models.function import FunctionBaseConfig from nat.object_store.models import ObjectStoreItem from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class ReportGenConfig(FunctionBaseConfig, name="report_gen"): """Configuration for the report generation tool.""" output_dir: str = Field(default="/tmp/agent_reports", description="Local directory to save report files (backup)") object_store: ObjectStoreRef = Field(description="Reference to the object store for serving files via HTTP") base_url: str | None = Field( default=None, description="Domain name of the machine, if not provided, public ip will be used" ) save_local_copy: bool = Field(default=True, description="Whether to also save a local copy of the report file") template_path: str = Field(default="", description="Path to template (relative to project root)") llm_name: str = Field( default="", description="Name of the LLM to use for custom report generation (required when template_type='custom')", ) template_name: str | None = Field( default=None, description="Name of the main template file to use for custom reports, if not provided, it will format message history to a markdown report", ) report_prompt: str = Field( default="", description="System prompt for the LLM to use when generating custom reports. Use {template} and {messages} as placeholders. Required when template_type='custom'.", ) class ReportGenInput(BaseModel): """Input for the report generation tool.""" messages: list[Any] | str = Field( ..., description="The list of messages that covers all important informationthat will be used to generate the report", ) class ReportGenOutput(BaseModel): """Output from the report generation tool.""" local_file_path: str = Field(..., description="Local file path where the report is saved") http_url: str = Field(..., description="HTTP URL to access the report file") object_store_key: str = Field(..., description="Key/filename in the object store") summary: str = Field(..., description="Brief summary of the report") file_size: int = Field(..., description="Size of the report file in bytes") content: str = Field(..., description="The actual markdown content of the generated report") def _format_messages_to_markdown(messages: list[Any]) -> str: """Format messages into markdown report.""" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") md_content = [ "# Deep Search Report", "", f"**Generated:** {timestamp}", f"**No. of Messages:** {len(messages)}", "", "---", "", "", ] for i, message in enumerate(messages, 1): md_content.append(f"### Message {i}") md_content.append("") # Extract message details message_type = type(message).__name__ md_content.append(f"**Message Type:** {message_type}") # Handle different message types if hasattr(message, "content"): content = getattr(message, "content", "") if content: md_content.append("**Content:**") md_content.append(f"```\n{content}\n```") # Handle tool calls in AIMessage if hasattr(message, "tool_calls") and message.tool_calls: md_content.append("**Tool Calls:**") for tool_call in message.tool_calls: tool_name = tool_call.get("name") or getattr(tool_call, "name", "Unknown") tool_args = tool_call.get("args") or getattr(tool_call, "args", {}) md_content.append(f"- **Tool:** {tool_name}") md_content.append(f" **Args:** {tool_args}") # Handle tool call id for ToolMessage if hasattr(message, "tool_call_id"): tool_call_id = getattr(message, "tool_call_id", "") md_content.append(f"**Tool Call ID:** {tool_call_id}") # Handle role/type if hasattr(message, "type"): role = getattr(message, "type", "") md_content.append(f"**Role:** {role}") md_content.append("") md_content.append("---") md_content.append("") # Add summary message_types: dict[str, int] = {} for message in messages: message_type = type(message).__name__ message_types[message_type] = message_types.get(message_type, 0) + 1 md_content.extend( [ "## Summary", "", f"- **Total Messages:** {len(messages)}", ] ) for msg_type, count in message_types.items(): md_content.append(f"- **{msg_type}:** {count}") # Add navigation footer md_content.extend( [ "---", "", "*This report was generated by the Metropolis Deep Search Report Generation Tool*", "", f"**File generated at:** {timestamp}", "", ] ) return "\n".join(md_content) def _load_custom_template(template_path: str, template_name: str) -> str: """Load a custom template from the specified path.""" # Check if this is a package resource path (e.g., "warehouse_report:templates") if ":" in template_path: package_name, resource_dir = template_path.split(":", 1) try: from importlib.resources import files package_files = files(package_name) resource_path = f"{resource_dir}/{template_name}" if resource_dir else template_name return (package_files / resource_path).read_text() except Exception as e: logger.error(f"Failed to load template {template_name} from package {package_name}: {e}") return f"# Report\n\nTemplate '{template_name}' could not be loaded from package '{package_name}'.\n\nError: {e}\n\n" else: # Regular file path full_template_path = Path(template_path) / template_name try: with open(full_template_path, encoding="utf-8") as f: return f.read() except Exception as e: logger.error(f"Failed to load custom template {template_name} from {template_path}: {e}") return ( f"# Report\n\nTemplate '{template_name}' could not be loaded from '{template_path}'.\n\nError: {e}\n\n" ) async def _format_custom_report( messages: list[Any], template_path: str, template_name: str, report_prompt: str, llm: Any ) -> str: """Format custom report using LLM to extract information from messages and populate template.""" if not llm: logger.warning("No LLM provided for custom report generation, falling back to conversation format") return _format_messages_to_markdown(messages) try: template_content = _load_custom_template(template_path, template_name) messages_text = "\n\n".join( [f"**{getattr(msg, 'type', type(msg).__name__)}**: {getattr(msg, 'content', str(msg))}" for msg in messages] ) # Substitute the template into the report_prompt, but escape template placeholders # so they don't get treated as prompt variables escaped_template = template_content.replace("{", "{{").replace("}", "}}") formatted_system_prompt = report_prompt.format(template=escaped_template) prompt_template = ChatPromptTemplate.from_messages( [("system", formatted_system_prompt), ("user", "Conversation to extract information from:\n\n{messages}")] ) chain = prompt_template | llm response = await chain.ainvoke({"messages": messages_text}) content: str = str(response.content).strip() # Remove markdown code blocks if present if content.startswith("```markdown"): content = content[11:-3] elif content.startswith("```"): content = content[3:-3] return content except Exception as e: logger.error(f"Error generating custom report with LLM: {e}") return _format_messages_to_markdown(messages) @register_function(config_type=ReportGenConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def report_gen(config: ReportGenConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """Tool for formatting agent conversation messages into markdown documents(reports) and serving them via HTTP.""" # Get the object store client object_store = await builder.get_object_store_client(config.object_store) async def _report_gen(trace_input: ReportGenInput) -> ReportGenOutput: """ This tool formats agent conversation messages into markdown documents, saves them to an object store, and provides HTTP URLs for easy access. It can also optionally save local copies. """ # Ensure messages is a list if isinstance(trace_input.messages, str): raise ValueError("messages must be a list of messages, not a string") if config.template_name: if not config.template_path: raise ValueError("template_path must be configured when template_type='custom'") if not config.llm_name: raise ValueError("llm_name must be configured when template_type='custom'") if not config.report_prompt: raise ValueError("report_prompt must be configured when template_type='custom'") # Get LLM for report generation try: llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) logger.debug(f"LLM {config.llm_name} loaded for custom report generation") except Exception as e: raise ValueError(f"Failed to load LLM {config.llm_name}: {e}") from e markdown_content = await _format_custom_report( messages=trace_input.messages, template_path=config.template_path, template_name=config.template_name, report_prompt=config.report_prompt, llm=llm, ) else: # Default to conversation format markdown_content = _format_messages_to_markdown( messages=trace_input.messages, ) # Generate filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"agent_report_{timestamp}.md" # Convert to bytes content_bytes = markdown_content.encode("utf-8") file_size = len(content_bytes) # Create object store item with metadata metadata = { "timestamp": timestamp, "generated_at": datetime.now().isoformat(), "messages_count": str(len(trace_input.messages)), "file_size": str(file_size), "content_type": "text/markdown", } object_store_item = ObjectStoreItem(data=content_bytes, content_type="text/markdown", metadata=metadata) # Save to object store await object_store.upsert_object(filename, object_store_item) # Generate HTTP URL if config.base_url: http_url = f"{config.base_url}/static/{filename}" else: # get public ip of the machine import urllib.request def get_public_ip() -> str: try: with urllib.request.urlopen("https://api.ipify.org") as response: result: str = response.read().decode("utf-8") return result except Exception: return "127.0.0.1" public_ip = get_public_ip() http_url = f"http://{public_ip}:8000/static/{filename}" # Save local copy if requested local_file_path = "" if config.save_local_copy: # Create output directory Path(config.output_dir).mkdir(parents=True, exist_ok=True) local_file_path = os.path.join(config.output_dir, filename) with open(local_file_path, "w", encoding="utf-8") as f: f.write(markdown_content) logger.info(f"Local report saved to: {local_file_path}") logger.info(f"Report saved to object store and available at: {http_url}") # Generate summary messages_count = len(trace_input.messages) summary = f"Report saved successfully with {messages_count} messages. \nAvailable at: {http_url}" return ReportGenOutput( local_file_path=local_file_path, http_url=http_url, object_store_key=filename, summary=summary, file_size=file_size, content=markdown_content, ) # Create function info with primary function function_info = FunctionInfo.create( single_fn=_report_gen, description=_report_gen.__doc__, input_schema=ReportGenInput, single_output_schema=ReportGenOutput, ) # Add additional utility functions # function_info.add_tool(_get_trace_info) # function_info.add_tool(_list_recent_traces) # function_info.add_tool(_delete_trace) yield function_info ================================================ FILE: agent/src/vss_agents/tools/rtvi_vlm_alert.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tool to configure real-time VLM stream monitoring via RTVI-VLM API.""" from collections.abc import AsyncGenerator import contextlib import json import logging import re from typing import Literal from urllib.parse import urlparse import aiohttp from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class RTVIVLMAlertConfig(FunctionBaseConfig, name="rtvi_vlm_alert"): """Configuration for the RTVI-VLM alert tool.""" rtvi_vlm_base_url: str = Field( ..., description="Base URL for RTVI-VLM service (e.g., http://localhost:8000)", ) vst_internal_url: str = Field( ..., description="Internal VST URL for API calls (e.g., http://${INTERNAL_IP}:30888)", ) va_get_incidents_tool: FunctionRef | None = Field( default=None, description="Optional reference to VA MCP get_incidents tool. If provided, reuses VA for incident queries instead of direct ES access.", ) default_model: str = Field( "nvidia/cosmos-reason1-7b", description="Default VLM model for caption/alert generation", ) default_chunk_duration: int = Field( 20, description="Default chunk duration in seconds", ) default_fps: int = Field( 1, description="Default frames per second to analyze", ) default_prompt: str | None = Field( None, description="Default detection prompt (if not provided via tool call)", ) default_system_prompt: str | None = Field( None, description="Default system prompt (if not provided via tool call)", ) timeout: int = Field(60, description="Request timeout in seconds") class RTVIVLMAlertInput(BaseModel): """Input for RTVI-VLM stream alert operations.""" action: Literal["start", "stop", "get_incidents"] = Field( ..., description="Action: 'start' (begin monitoring), 'stop' (end monitoring), 'get_incidents' (query detected incidents)", ) sensor_name: str | None = Field( None, description="Sensor name (e.g., HWY_20_AND_DEVON__WB). Required for all actions.", ) prompt: str | None = Field( None, description="Detection prompt (e.g., 'Is there a vehicle collision? Answer YES or NO.'). Only for 'start' action.", ) system_prompt: str | None = Field( None, description="System prompt for VLM. Only for 'start' action.", ) # Fields for get_incidents action start_time: str | None = Field( None, description="Start time in ISO 8601 format (e.g., 2026-01-06T00:00:00.000Z). Only for 'get_incidents' action.", ) end_time: str | None = Field( None, description="End time in ISO 8601 format. Only for 'get_incidents' action.", ) max_count: int = Field( 10, description="Maximum number of incidents to return. Only for 'get_incidents' action.", ) incident_type: str | None = Field( None, description="Filter by incident type (e.g., 'collision'). Only for 'get_incidents' action.", ) class RTVIVLMAlertOutput(BaseModel): """Output from RTVI-VLM alert operations.""" success: bool = Field(..., description="Whether the operation succeeded") sensor_name: str | None = Field(default=None, description="Sensor name") stream_id: str | None = Field(default=None, description="RTVI-VLM stream ID (UUID)") message: str = Field(..., description="Status message") incidents: list[dict] | None = Field(default=None, description="List of incidents (for get_incidents action)") total_count: int | None = Field( default=None, description="Total number of incidents found (for get_incidents action)" ) # In-memory mapping of sensor_name -> rtvi_stream_id (for stop action) _sensor_to_rtvi_stream_id: dict[str, str] = {} @register_function(config_type=RTVIVLMAlertConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def rtvi_vlm_alert(config: RTVIVLMAlertConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Start or stop real-time VLM alert monitoring for a sensor. Actions: - start: Add stream to RTVI-VLM + start caption/alert generation - stop: Stop caption generation + delete stream Both actions use sensor_name only. The RTSP URL is fetched from VST live streams API. """ async def _get_live_streams() -> dict[str, dict]: """Fetch live streams from VST. Returns mapping of sensor_name -> {"stream_id": ..., "url": ...}.""" vst_url = f"{config.vst_internal_url.rstrip('/')}/vst/api/v1/live/streams" timeout = aiohttp.ClientTimeout(total=config.timeout) async with aiohttp.ClientSession(timeout=timeout) as session, session.get(vst_url) as response: response.raise_for_status() # VST returns text/plain content type but body is JSON streams_data = json.loads(await response.text()) # Parse response: [{"stream_id": [{"name": ..., "url": ..., "streamId": ...}]}, ...] result = {} for item in streams_data: for stream_id, streams in item.items(): if streams and isinstance(streams, list): stream_info = streams[0] name = stream_info.get("name") url = stream_info.get("url") if name and url: result[name] = {"stream_id": stream_id, "url": url} return result async def _rtvi_vlm_alert(input_data: RTVIVLMAlertInput) -> RTVIVLMAlertOutput: """Execute RTVI-VLM stream alert operation.""" base_url = config.rtvi_vlm_base_url.rstrip("/") logger.info(f"RTVI-VLM base URL: {base_url}") timeout = aiohttp.ClientTimeout(total=config.timeout) sensor_name = input_data.sensor_name # === GET_INCIDENTS === Query incidents via VA MCP tool if input_data.action == "get_incidents": if not sensor_name: return RTVIVLMAlertOutput( success=False, message="sensor_name is required for 'get_incidents' action.", ) # Check if VA tool is configured if not config.va_get_incidents_tool: return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, message="va_get_incidents_tool is not configured. Cannot query incidents.", ) try: # Get the VA get_incidents tool va_tool = await builder.get_tool(config.va_get_incidents_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) # Build input for VA tool - use sensor_name directly as source # When sensor_name is provided to RTVI-VLM, it's used as sensor_id in Kafka messages va_input = { "source": sensor_name, "source_type": "sensor", "max_count": input_data.max_count, } # Add time range if provided (VA tool requires both start and end) if input_data.start_time and input_data.end_time: va_input["start_time"] = input_data.start_time va_input["end_time"] = input_data.end_time # Call VA tool result = await va_tool.ainvoke(input=va_input) # Parse result - VA tool returns {"incidents": [...], "has_more": bool} if isinstance(result, str): result = json.loads(result) incidents = result.get("incidents", []) total = len(incidents) return RTVIVLMAlertOutput( success=True, sensor_name=sensor_name, message=f"Found {total} incidents for sensor '{sensor_name}'.", incidents=incidents, total_count=total, ) except Exception as e: logger.error(f"VA get_incidents error: {e}") return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, message=f"Failed to query incidents: {e}", ) # Validate sensor_name for start/stop actions if input_data.action in ("start", "stop") and not sensor_name: return RTVIVLMAlertOutput( success=False, message=f"sensor_name is required for action '{input_data.action}'.", ) try: async with aiohttp.ClientSession(timeout=timeout) as session: # === START === if input_data.action == "start": # Fetch live streams and find the sensor's RTSP URL live_streams = await _get_live_streams() if sensor_name not in live_streams: return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, message=f"Sensor '{sensor_name}' not found in VST live streams. " f"Available sensors: {sorted(live_streams.keys())}", ) # Get the RTSP URL from VST and replace internal IP with VST host IP rtsp_url = live_streams[sensor_name]["url"] vst_host = urlparse(config.vst_internal_url).hostname rtsp_url = re.sub(r"rtsp://[\d.]+:", f"rtsp://{vst_host}:", rtsp_url) logger.info(f"Starting RTVI-VLM alert for sensor: {sensor_name}, RTSP: {rtsp_url}") # Step 1: Add stream add_payload = { "streams": [ { "liveStreamUrl": rtsp_url, "description": sensor_name, "sensor_name": sensor_name, } ] } async with session.post(f"{base_url}/v1/streams/add", json=add_payload) as response: if response.status != 200: error = await response.text() return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, message=f"Failed to add stream: {error}", ) result = await response.json() rtvi_stream_id = result.get("results", [{}])[0].get("id") if not rtvi_stream_id: return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, message=f"Failed to get rtvi_stream_id from response: {result}", ) logger.info(f"Stream added with RTVI ID: {rtvi_stream_id}") # Save mapping for stop action (in-memory only) _sensor_to_rtvi_stream_id[sensor_name] = rtvi_stream_id # Step 2: Start caption/alert generation # Use prompt from: tool input > config default > generic fallback prompt = ( input_data.prompt or config.default_prompt or "Describe any notable events or anomalies in this video stream." ) system_prompt = ( input_data.system_prompt or config.default_system_prompt or "You are a video monitoring assistant. Provide detailed observations about relevant events." ) caption_payload = { "id": rtvi_stream_id, "model": config.default_model, "stream": True, "chunk_duration": config.default_chunk_duration, "num_frames_per_second_or_fixed_frames_chunk": config.default_fps, "use_fps_for_chunking": True, "prompt": prompt, "system_prompt": system_prompt, } async with session.post( f"{base_url}/v1/generate_captions_alerts", json=caption_payload ) as response: if response.status != 200: error = await response.text() # Try to clean up the added stream with contextlib.suppress(Exception): await session.delete(f"{base_url}/v1/streams/delete/{rtvi_stream_id}") return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, stream_id=rtvi_stream_id, message=f"Stream added but failed to start monitoring: {error}", ) return RTVIVLMAlertOutput( success=True, sensor_name=sensor_name, stream_id=rtvi_stream_id, message=f"Real-time VLM alert started for sensor {sensor_name}.", ) # === STOP === elif input_data.action == "stop": assert sensor_name is not None # validated above for stop action # Get rtvi_stream_id from mapping rtvi_stream_id = _sensor_to_rtvi_stream_id.get(sensor_name) if not rtvi_stream_id: return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, message=f"No active alert found for sensor '{sensor_name}'. " f"Active sensors: {list(_sensor_to_rtvi_stream_id.keys())}", ) logger.info(f"Stopping RTVI-VLM alert for sensor: {sensor_name}, rtvi_stream_id: {rtvi_stream_id}") # Step 1: Stop caption generation try: async with session.delete( f"{base_url}/v1/generate_captions_alerts/{rtvi_stream_id}" ) as response: if response.status not in (200, 204, 404): error = await response.text() logger.warning(f"Failed to stop captions: {error}") except Exception as e: logger.warning(f"Error stopping captions: {e}") # Step 2: Delete stream async with session.delete(f"{base_url}/v1/streams/delete/{rtvi_stream_id}") as response: # Remove from mapping regardless of result _sensor_to_rtvi_stream_id.pop(sensor_name, None) if response.status in (200, 204): return RTVIVLMAlertOutput( success=True, sensor_name=sensor_name, stream_id=rtvi_stream_id, message=f"Real-time VLM alert stopped for sensor {sensor_name}.", ) elif response.status == 404: return RTVIVLMAlertOutput( success=True, sensor_name=sensor_name, stream_id=rtvi_stream_id, message=f"Alert for sensor {sensor_name} was already stopped.", ) else: error = await response.text() return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, stream_id=rtvi_stream_id, message=f"Failed to delete stream: {error}", ) except aiohttp.ClientError as e: logger.error(f"RTVI-VLM connection error: {e}") return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, message=f"Connection error: {e}", ) except Exception as e: logger.error(f"RTVI-VLM operation failed: {e}") return RTVIVLMAlertOutput( success=False, sensor_name=sensor_name, message=str(e), ) yield FunctionInfo.create( single_fn=_rtvi_vlm_alert, description=_rtvi_vlm_alert.__doc__, input_schema=RTVIVLMAlertInput, single_output_schema=RTVIVLMAlertOutput, ) ================================================ FILE: agent/src/vss_agents/tools/s3_picture_url.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 from collections.abc import AsyncGenerator import logging import boto3 import cv2 from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class S3PictureURLConfig(FunctionBaseConfig, name="s3_picture_url"): """Configuration for the S3 Picture URL tool.""" minio_url: str = Field( "http://localhost:9000", description="The endpoint URL of the MinIO server", ) access_key: str = Field( "minioadmin", description="The access key of the S3 bucket", ) secret_key: str = Field( "minioadmin", description="The secret key of the S3 bucket", ) bucket_name: str = Field( "my-bucket", description="The name of the S3 bucket to use for video storage", ) class S3PictureURLInput(BaseModel): """Input for the S3 Picture URL tool""" sensor_id: str = Field( ..., description="The stream ID to get video URL for", min_length=1, ) class S3PictureURLOutput(BaseModel): """Output for the VST Video URL tool""" image_url: str = Field( ..., description="Direct URL to access the image file", ) base64_frame: str = Field( ..., description="Base64 encoded frame", ) video_url: str = Field( ..., description="Direct URL to access the video file", ) @register_function(config_type=S3PictureURLConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def s3_picture_url(config: S3PictureURLConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: s3_client = boto3.client( "s3", endpoint_url=config.minio_url, aws_access_key_id=config.access_key, aws_secret_access_key=config.secret_key, region_name="us-east-1", verify=True, ) async def _s3_picture_url(s3_picture_url_input: S3PictureURLInput) -> S3PictureURLOutput: """ S3 Picture URL tool that gets the first frame from a stored video file in the s3 bucket. Input: sensor_id: str, the sensor ID of the video to get the picture URL for Output: picture_url: str, the URL of the first frame of the video, served from the S3 bucket """ try: logger.info(f"Getting video URL for sensor {s3_picture_url_input.sensor_id}") # # use cv2 to get the first frame of the video video_path = s3_client.generate_presigned_url( "get_object", Params={ "Bucket": config.bucket_name, "Key": s3_picture_url_input.sensor_id + ".mp4", }, ExpiresIn=3600, ) cap = cv2.VideoCapture(video_path) _ret, frame = cap.read() cap.release() _, buffer = cv2.imencode(".jpg", frame) # store the frame as jpg in the S3 bucket file_name = s3_picture_url_input.sensor_id + ".jpg" # use s3 client to upload the frame to the S3 bucket s3_client.put_object( Bucket=config.bucket_name, Key=file_name, Body=buffer.tobytes(), ContentType="image/jpeg", ) image_url = s3_client.generate_presigned_url( "get_object", Params={ "Bucket": config.bucket_name, "Key": file_name, }, ExpiresIn=3600, ) base64_frame = base64.b64encode(buffer.tobytes()).decode("utf-8") return S3PictureURLOutput( image_url=image_url, base64_frame=base64_frame, video_url=video_path, ) except Exception as e: logger.error(f"Error getting S3 video/picture URL: {e}") raise yield FunctionInfo.create( single_fn=_s3_picture_url, description=_s3_picture_url.__doc__, input_schema=S3PictureURLInput, single_output_schema=S3PictureURLOutput, ) ================================================ FILE: agent/src/vss_agents/tools/search.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio from collections.abc import AsyncGenerator from datetime import datetime from datetime import timedelta import json import logging from typing import Any from typing import Literal from typing import Union import aiohttp from fastapi import HTTPException from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.api_server import ChatRequest from nat.data_models.api_server import ChatResponse from nat.data_models.api_server import ChatResponseChunk from nat.data_models.api_server import Usage from nat.data_models.component_ref import FunctionRef from nat.data_models.component_ref import LLMRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field from vss_agents.agents.data_models import AgentMessageChunk from vss_agents.agents.data_models import AgentMessageChunkType from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.vst.utils import get_streams_info from vss_agents.utils.reasoning_utils import get_llm_reasoning_bind_kwargs from vss_agents.utils.reasoning_utils import get_thinking_tag from vss_agents.utils.time_convert import datetime_to_iso8601 from vss_agents.utils.time_convert import iso8601_to_datetime logger = logging.getLogger(__name__) # Prompt template for query decomposition with placeholders QUERY_DECOMPOSITION_PROMPT = """You are a search query analyzer. Extract structured search parameters from natural language queries. Available video sources: {video_sources} Extract the following parameters from the user query: - query: The main search description including actions AND attributes (e.g., "person moving with white pants") - video_sources: List of video source names mentioned (from available sources above, empty list if none mentioned) - source_type: "rtsp" if referring to live/camera streams, "video_file" if referring to uploaded video files (default: "video_file") - timestamp_start: Start time in ISO format (e.g., "2025-01-01T13:00:00Z"). Use 2025-01-01 as the base date. - timestamp_end: End time in ISO format (e.g., "2025-01-01T14:00:00Z"). Use 2025-01-01 as the base date. - attributes: List of person with attributes, ONLY. Don't include other objects, don't just put "person". - has_action: REQUIRED boolean. Set to True if the query explicitly mentions an action/event/activity (e.g., running, walking, carrying, pushing, entering, leaving, moving). Set to False if the query only describes visual/physical attributes (what someone/something LOOKS LIKE) without any action. Examples: "person" → false, "person walking" → true, "red car" → false, "person carrying box" → true, "forklift" → false. - top_k: Number of results to return (integer, only if explicitly mentioned, e.g., "top 5", "first 10") - min_cosine_similarity: Minimum similarity threshold between -1.0 and 1.0 (e.g., "highly similar" = 0.8, "somewhat similar" = 0.5, "exact match" = 0.9, "any match" = -1.0) Examples: {few_shot_examples} Return ONLY a valid JSON object with the extracted parameters. If a parameter cannot be determined, omit it or use null. User query: {user_query}""" # Default few-shot examples for query decomposition DEFAULT_FEW_SHOT_EXAMPLES = """Example 1: User query: "Find a man pushing a cart wearing a beige shirt between 1 pm and 2 pm at Endeavor heart" Output: {{"query": "man pushing cart wearing beige shirt", "video_sources": ["Endeavor heart"], "source_type": "rtsp", "timestamp_start": "2025-01-01T13:00:00Z", "timestamp_end": "2025-01-01T14:00:00Z", "attributes": ["person wearing beige shirt"], "has_action": true}} Example 2: User query: "Find people running near Building A camera from 9am to 10am" Output: {{"query": "people running", "video_sources": ["Building A"], "source_type": "rtsp", "timestamp_start": "2025-01-01T09:00:00Z", "timestamp_end": "2025-01-01T10:00:00Z", "has_action": true}} Example 3: User query: "Search for a woman with a blue backpack walking" Output: {{"query": "woman walking with blue backpack", "video_sources": [], "source_type": "video_file", "attributes": ["woman with blue backpack"], "has_action": true}} Example 4: User query: "Find delivery truck at warehouse entrance between 2pm and 4pm" Output: {{"query": "delivery truck at warehouse entrance", "video_sources": ["warehouse entrance"], "source_type": "rtsp", "timestamp_start": "2025-01-01T14:00:00Z", "timestamp_end": "2025-01-01T16:00:00Z", "has_action": false}} Example 5: User query: "Person wearing red jacket and blue jeans carrying a box" Output: {{"query": "person wearing red jacket and blue jeans carrying box", "video_sources": [], "source_type": "video_file", "attributes": ["person wearing red jacket and blue jeans"], "has_action": true}} Example 7: User query: "person with long wavy hair wearing white sneakers" Output: {{"query": "person with long wavy hair wearing white sneakers", "video_sources": [], "source_type": "video_file", "attributes": ["person with long wavy hair wearing white sneakers"], "has_action": false}} Example 8: User query: "Person in white t-shirt and black leggings running out of store with stolen items" Output: {{"query": "person in white t-shirt and black leggings running out of store with stolen items", "video_sources": [], "source_type": "video_file", "attributes": ["person in white t-shirt and black leggings"], "has_action": true}}""" class DecomposedQuery(BaseModel): """Result of query decomposition.""" query: str = Field(default="", description="The main search query") video_sources: list[str] = Field(default_factory=list, description="List of video source names") source_type: str = Field(default="video_file", description="Type of source: 'rtsp' or 'video_file'") timestamp_start: str | None = Field(default=None, description="Start timestamp in ISO format") timestamp_end: str | None = Field(default=None, description="End timestamp in ISO format") attributes: list[str] = Field(default_factory=list, description="List of attributes to filter by") has_action: bool | None = Field( default=None, description="True if query contains an action/event/activity, False if only visual/physical attributes", ) top_k: int | None = Field(default=None, description="Number of results to return") min_cosine_similarity: float | None = Field(default=None, description="Minimum similarity threshold (-1.0 to 1.0)") async def _run_attribute_only_search( attribute_list: list[str], search_input: "SearchInput", attribute_search_fn: Any, top_k: int, min_similarity: float | None, exclude_videos: list[dict[str, str]] | None = None, ) -> list["SearchResult"]: """ Modular helper function to run attribute-only search. Returns list of SearchResult from attribute search in append mode. """ logger.info("Running attribute-only search (append mode)") exclude_videos = exclude_videos or [] try: attr_params = { "query": attribute_list, "source_type": search_input.source_type, "video_sources": search_input.video_sources, "timestamp_start": search_input.timestamp_start, "timestamp_end": search_input.timestamp_end, "top_k": top_k, "min_similarity": min_similarity if min_similarity is not None else 0.3, "fuse_multi_attribute": False, # Append mode - no fusion "exclude_videos": exclude_videos, } attribute_results = await attribute_search_fn.ainvoke(attr_params) # Convert AttributeSearchResult to SearchResult search_results = [] if attribute_results and isinstance(attribute_results, list): from vss_agents.tools.attribute_search import AttributeSearchResult validated_results = [ item if isinstance(item, AttributeSearchResult) else AttributeSearchResult.model_validate(item) for item in attribute_results ] for result in validated_results: try: search_result = attribute_result_to_search_result( result, ) search_results.append(search_result) except Exception as e: logger.warning(f"Failed to convert attribute result: {e}") continue # Sort by similarity (descending) search_results.sort(key=lambda x: x.similarity, reverse=True) return search_results except Exception as e: logger.error(f"Attribute-only search failed: {e}", exc_info=True) return [] def attribute_result_to_search_result( attr_result: Any, video_name: str | None = None, description: str = "", ) -> "SearchResult": """ Convert AttributeSearchResult to SearchResult. Args: attr_result: AttributeSearchResult instance or dict video_name: Optional video name (defaults to sensor_id) description: Optional description """ from vss_agents.tools.attribute_search import AttributeSearchResult # Validate and convert to AttributeSearchResult if needed if isinstance(attr_result, dict): validated_result = AttributeSearchResult.model_validate(attr_result) elif isinstance(attr_result, AttributeSearchResult): validated_result = attr_result else: validated_result = AttributeSearchResult.model_validate(attr_result) metadata = validated_result.metadata # Use frame_score if available, otherwise behavior_score similarity = ( float(metadata.frame_score) if (metadata.frame_score is not None and metadata.frame_score > 0.0) else float(metadata.behavior_score) ) # Use start_time and end_time from metadata (set from behavior embedding timestamps in _build_result). # For pure attribute search, these are always from behavior embedding source (timestamp and end fields). # When duplicates are merged, they reflect the earliest start and latest end from all duplicates. # Fallback to frame_timestamp only if somehow missing (shouldn't happen if source has timestamps). start_time = metadata.start_time if metadata.start_time else metadata.frame_timestamp end_time = metadata.end_time if metadata.end_time else metadata.frame_timestamp # Use video_name from metadata (set to original sensor name before converting sensor_id to UUID) result_video_name = video_name or metadata.video_name or metadata.sensor_id # Build description with timestamp if not provided if not description: description = f"Attribute match at {metadata.frame_timestamp}" return SearchResult( video_name=result_video_name, description=description, start_time=start_time, end_time=end_time, sensor_id=metadata.sensor_id, screenshot_url=validated_result.screenshot_url or "", similarity=similarity, object_ids=[str(metadata.object_id)], ) async def decompose_query( user_query: str, llm: Any, video_file_names: list[str] | None = None, video_stream_names: list[str] | None = None, few_shot_examples: str | None = None, ) -> DecomposedQuery: """ Decompose a natural language query into structured search parameters using an LLM. Args: user_query: The natural language query from the user llm: The LLM instance to use for decomposition video_file_names: Optional list of available video file names video_stream_names: Optional list of available video stream names few_shot_examples: Optional custom few-shot examples for the prompt Returns: DecomposedQuery with extracted parameters """ # Build video sources string video_sources_parts = [] if video_file_names: video_sources_parts.append(f"Video files: {', '.join(video_file_names)}") if video_stream_names: video_sources_parts.append(f"Video streams: {', '.join(video_stream_names)}") video_sources_str = "\n".join(video_sources_parts) if video_sources_parts else "No specific sources available" # Use default examples if not provided examples = few_shot_examples or DEFAULT_FEW_SHOT_EXAMPLES # Format the prompt prompt = QUERY_DECOMPOSITION_PROMPT.format( video_sources=video_sources_str, few_shot_examples=examples, user_query=user_query, ) # Add thinking tag to disable reasoning if applicable to llm thinking_tag = get_thinking_tag(llm, False) system_content = "You are a helpful assistant that extracts search parameters from natural language queries. Return only valid JSON." if thinking_tag: system_content += f"\n{thinking_tag}" logger.debug(f"Added thinking tag to system message: {thinking_tag}") # Build messages messages = [ SystemMessage(content=system_content), HumanMessage(content=prompt), ] # Bind LLM with reasoning kwargs if the model supports it llm_kwargs = get_llm_reasoning_bind_kwargs(llm, False) llm_to_use = llm.bind(**llm_kwargs) if llm_kwargs else llm try: llm_response = await llm_to_use.ainvoke(messages) response_content = llm_response.content if hasattr(llm_response, "content") else str(llm_response) # Parse JSON response (handle markdown code blocks) response_text = response_content.strip() if "```json" in response_text: start = response_text.find("```json") + 7 end = response_text.find("```", start) response_text = response_text[start:end].strip() if end != -1 else response_text[start:].strip() elif "```" in response_text: start = response_text.find("```") + 3 end = response_text.find("```", start) response_text = response_text[start:end].strip() if end != -1 else response_text[start:].strip() extracted = json.loads(response_text) # Parse top_k if present top_k = None if extracted.get("top_k") is not None: try: top_k = int(extracted["top_k"]) except (ValueError, TypeError): logger.debug("Failed to parse top_k value: %s", extracted["top_k"]) # Parse min_cosine_similarity if present min_cosine_similarity = None if extracted.get("min_cosine_similarity") is not None: try: min_cosine_similarity = float(extracted["min_cosine_similarity"]) except (ValueError, TypeError): logger.debug("Failed to parse min_cosine_similarity value: %s", extracted["min_cosine_similarity"]) # Parse has_action if present has_action = None if extracted.get("has_action") is not None: try: has_action = bool(extracted["has_action"]) except (ValueError, TypeError): logger.debug("Failed to parse has_action value: %s", extracted["has_action"]) return DecomposedQuery( query=extracted.get("query", user_query), video_sources=extracted.get("video_sources", []) or [], source_type=extracted.get("source_type", "video_file") or "video_file", timestamp_start=extracted.get("timestamp_start"), timestamp_end=extracted.get("timestamp_end"), attributes=extracted.get("attributes", []) or [], has_action=has_action, top_k=top_k, min_cosine_similarity=min_cosine_similarity, ) except Exception as e: logger.warning(f"Failed to decompose query, using original: {e}") return DecomposedQuery(query=user_query) def _apply_weighted_linear_fusion( video_data: list[dict[str, Any]], w_embed: float, w_attribute: float, ) -> list["SearchResult"]: """ Apply weighted linear fusion: (w_embed x embed_score) + (w_attribute x normalised_attribute_score). returns list of SearchResult sorted by fusion score (descending) """ reranked_results = [] for video in video_data: embed_score = video["embed_score"] attribute_score = video["normalised_attribute_score"] fusion_score = w_embed * embed_score + w_attribute * attribute_score logger.info( f"Weighted Linear: {video['embed_result'].video_name} - " f"embed={embed_score:.3f} (w={w_embed:.2f}), " f"attribute={attribute_score:.3f} (w={w_attribute:.2f}), fusion_score={fusion_score:.3f}" ) reranked_result = SearchResult( video_name=video["embed_result"].video_name, description=video["embed_result"].description, start_time=video["embed_result"].start_time, end_time=video["embed_result"].end_time, sensor_id=video["embed_result"].sensor_id, screenshot_url=video["screenshot_url"], similarity=fusion_score, object_ids=video["object_ids"], ) reranked_results.append((fusion_score, reranked_result)) # Sort by fusion score (descending) reranked_results.sort(key=lambda x: x[0], reverse=True) return [result for _, result in reranked_results] def _apply_rrf_fusion( video_data: list[dict[str, Any]], rrf_k: int, rrf_w: float, ) -> list["SearchResult"]: """ Apply Reciprocal Rank Fusion (RRF): 1/(rank_action + k) + w*normalised_attribute_score. returns list of SearchResult sorted by RRF score (descending) """ # Sort by embed_score (descending) to determine rank_action sorted_video_data = sorted(video_data, key=lambda x: x["embed_score"], reverse=True) reranked_results = [] for rank, video in enumerate(sorted_video_data, start=1): rank_action = rank rrf_score = 1.0 / (rank_action + rrf_k) + rrf_w * video["normalised_attribute_score"] logger.info( f"RRF: {video['embed_result'].video_name} - " f"rank_action={rank_action}, normalised_attribute_score={video['normalised_attribute_score']:.3f}, " f"rrf_score={rrf_score:.6f}" ) reranked_result = SearchResult( video_name=video["embed_result"].video_name, description=video["embed_result"].description, start_time=video["embed_result"].start_time, end_time=video["embed_result"].end_time, sensor_id=video["embed_result"].sensor_id, screenshot_url=video["screenshot_url"], similarity=rrf_score, object_ids=video["object_ids"], ) reranked_results.append((rrf_score, reranked_result)) # Sort by RRF score (descending) reranked_results.sort(key=lambda x: x[0], reverse=True) return [result for _, result in reranked_results] def _apply_rrf_fusion_with_attribute_rank( video_data: list[dict[str, Any]], rrf_k: int, rrf_w: float, ) -> list["SearchResult"]: """ Apply Reciprocal Rank Fusion (RRF) using both embed and attribute ranks: 1/(rank_embed + k) + w * 1/(rank_attribute + k). Sorts videos by both embed_score and attribute_score to determine ranks, then applies RRF formula with both reciprocal ranks. The rrf_w parameter weights the attribute rank component. returns list of SearchResult sorted by RRF score (descending) """ # Sort by embed_score to determine rank_embed sorted_by_embed = sorted(video_data, key=lambda x: x["embed_score"], reverse=True) embed_ranks = {id(video): rank for rank, video in enumerate(sorted_by_embed, start=1)} # Sort by normalised_attribute_score to determine rank_attribute sorted_by_attribute = sorted(video_data, key=lambda x: x["normalised_attribute_score"], reverse=True) attribute_ranks = {id(video): rank for rank, video in enumerate(sorted_by_attribute, start=1)} reranked_results = [] for video in video_data: rank_embed = embed_ranks[id(video)] rank_attribute = attribute_ranks[id(video)] rrf_score = 1.0 / (rank_embed + rrf_k) + rrf_w * (1.0 / (rank_attribute + rrf_k)) logger.info( f"RRF (both ranks): {video['embed_result'].video_name} - " f"rank_embed={rank_embed}, rank_attribute={rank_attribute}, " f"rrf_score={rrf_score:.6f}" ) reranked_result = SearchResult( video_name=video["embed_result"].video_name, description=video["embed_result"].description, start_time=video["embed_result"].start_time, end_time=video["embed_result"].end_time, sensor_id=video["embed_result"].sensor_id, screenshot_url=video["screenshot_url"], similarity=rrf_score, object_ids=video["object_ids"], ) reranked_results.append((rrf_score, reranked_result)) # Sort by RRF score (descending) reranked_results.sort(key=lambda x: x[0], reverse=True) return [result for _, result in reranked_results] async def fusion_search_rerank( embed_results: list["SearchResult"], attributes: list[str], attribute_search_fn: Any, vst_internal_url: str | None = None, source_type: str = "video_file", fusion_method: str = "rrf", rrf_k: int = 60, rrf_w: float = 0.5, w_attribute: float = 0.55, w_embed: float = 0.35, ) -> list["SearchResult"]: """ Rerank embed_search results using either Weighted Linear Fusion or Reciprocal Rank Fusion (RRF). For each video: 1. Run attribute_search for each attribute 2. Compute normalized attribute score (sum of attribute scores / number of attributes searched) 3. Apply fusion method: - Weighted Linear: weighted sum of scores - RRF: rank by embed_score, then apply RRF formula returns reranked list of SearchResult with fused scores """ logger.info( f"{fusion_method.upper()} fusion reranking {len(embed_results)} videos using {len(attributes)} attributes" ) # Prepare attribute search tasks for all embed results (run in parallel) async def _get_attribute_results(embed_result: "SearchResult") -> tuple["SearchResult", Any]: """Prepare and call attribute search for one embed result.""" try: # Convert ISO timestamp strings to datetime objects start_dt = iso8601_to_datetime(embed_result.start_time) end_dt = iso8601_to_datetime(embed_result.end_time) # If start and end times are the same or end is before/at start (single timestamp or 0-duration clip), # expand to ±2.5 seconds for attribute search if end_dt <= start_dt: original_start = start_dt start_dt = original_start - timedelta(seconds=2.5) end_dt = original_start + timedelta(seconds=2.5) logger.info( f"Extended 0-duration clip to ±2.5 seconds: {embed_result.start_time} -> [{datetime_to_iso8601(start_dt)}, {datetime_to_iso8601(end_dt)}]" ) # Convert stream_id (from embed_result.sensor_id) to sensor_id (sensor name) for attribute_search # attribute_search filters by sensor.id.keyword which expects camera names like "warehouse_sample_test" filter_sensor_id = "" # Try VST conversion if sensor_id exists if embed_result.sensor_id and vst_internal_url: try: from vss_agents.tools.vst.utils import get_sensor_id_from_stream_id filter_sensor_id = await get_sensor_id_from_stream_id(embed_result.sensor_id, vst_internal_url) if filter_sensor_id != embed_result.sensor_id: logger.info(f"Converted stream_id '{embed_result.sensor_id}' to sensor_id '{filter_sensor_id}'") except Exception as e: logger.warning(f"VST conversion failed: {e}. Using fallback") # Fallback chain: video_name -> sensor_id -> "" if not filter_sensor_id: filter_sensor_id = embed_result.video_name or embed_result.sensor_id or "" # Call attribute_search once with all attributes (will generate one video with all overlays) # Use fuse_multi_attribute=True for fusion path (combines object IDs) # Convert sensor_id to video_sources format (supports wildcard matching) attr_params = { "query": attributes, "source_type": source_type, "video_sources": [filter_sensor_id] if filter_sensor_id else None, "timestamp_start": start_dt, "timestamp_end": end_dt, "top_k": 1, "min_similarity": 0.4, "fuse_multi_attribute": True, } try: attribute_results = await attribute_search_fn.ainvoke(attr_params) except Exception as e: logger.error(f"Attribute search failed for {embed_result.video_name}: {e}") attribute_results = None return embed_result, attribute_results except Exception as e: logger.error(f"Failed to process embed result {embed_result.video_name}: {e}") return embed_result, None # Run all attribute searches in parallel results_list = await asyncio.gather(*[_get_attribute_results(er) for er in embed_results]) # First pass: collect all scores video_data: list[dict[str, Any]] = [] for embed_result, attribute_results in results_list: embed_score = embed_result.similarity # Collect similarity scores, screenshot URL, and object IDs from attribute search results attribute_scores = [] attribute_screenshot_url = None object_ids = [] # Process and validate the attribute search result if attribute_results and isinstance(attribute_results, list): from vss_agents.tools.attribute_search import AttributeSearchResult validated_results = [ item if isinstance(item, AttributeSearchResult) else AttributeSearchResult.model_validate(item) for item in attribute_results ] else: validated_results = [] # Iterate over all returned results (fuse mode may return fewer results than attributes # when some attributes have no matches, so we must NOT zip with attributes). if validated_results: for result in validated_results: # Prioritize frame_score, fall back to behavior_score frame_score = result.metadata.frame_score behavior_score = result.metadata.behavior_score score = float(frame_score) if (frame_score is not None and frame_score > 0.0) else float(behavior_score) attribute_scores.append(score) # Extract object_id from metadata object_id = result.metadata.object_id if object_id and str(object_id) not in object_ids: object_ids.append(str(object_id)) # Extract screenshot URL from first result (all results have the same URL) attribute_screenshot_url = validated_results[0].screenshot_url or "" # Compute normalized attribute score (normalised_attribute_score) # Divide by number of attributes searched (not matched) to penalize videos that don't match all attributes normalised_attribute_score = sum(attribute_scores) / len(attributes) if len(attributes) > 0 else 0.0 video_data.append( { "embed_result": embed_result, "embed_score": embed_score, "normalised_attribute_score": normalised_attribute_score, "screenshot_url": attribute_screenshot_url if attribute_screenshot_url else embed_result.screenshot_url, "object_ids": object_ids, } ) logger.info( f"Collecting scores: {embed_result.video_name} ({embed_result.start_time} to {embed_result.end_time}), " f"embed={embed_score:.3f}, normalised_attribute_score={normalised_attribute_score:.3f} " f"({len(attribute_scores)}/{len(attributes)} matched)" ) # Second pass: Apply fusion method if fusion_method == "weighted_linear": final_results = _apply_weighted_linear_fusion(video_data, w_embed, w_attribute) elif fusion_method == "rrf": final_results = _apply_rrf_fusion(video_data, rrf_k, rrf_w) elif fusion_method == "rrf_with_attribute_rank": final_results = _apply_rrf_fusion_with_attribute_rank(video_data, rrf_k, rrf_w) else: raise ValueError( f"Unknown fusion_method: {fusion_method}. Must be 'weighted_linear', 'rrf', or 'rrf_with_attribute_rank'" ) logger.info(f"{fusion_method.upper()} fusion reranking complete: {len(final_results)} videos reranked") return final_results # ===== SHARED CORE SEARCH LOGIC ===== # This function contains the core search logic used by both search.py and search_agent.py # Uses async generator pattern for real-time streaming support async def execute_core_search( search_input: "SearchInput", embed_search: Any, # Function reference for embed search agent_llm: Any | None, # LLM for query decomposition config: Any, # SearchConfig or similar config object builder: Builder, # Builder for getting tools attribute_search_fn: Any | None = None, # Function reference for attribute search (can be loaded from builder if None) critic_agent: Any | None = None, # Optional critic agent ) -> AsyncGenerator[Union[AgentMessageChunk, "SearchOutput"]]: """ Core search execution logic shared by search.py and search_agent.py. This is an async generator that yields progress updates, then the final SearchOutput. For non-streaming use, use execute_core_search_wrapper() wrapper. This function implements the three-path architecture: 1. Attribute-only search (if has_action=False and attributes exist) 2. Embed-only search (if no attributes) 3. Fusion search (if has_action=True and attributes exist, with confidence threshold check) Args: search_input: SearchInput with query and filters embed_search: Function reference for embed search agent_llm: LLM for query decomposition (if agent_mode=True) config: Config object with search settings (must have: attribute_search_tool, use_attribute_search, embed_confidence_threshold, vst_internal_url, fusion_method, rrf_k, rrf_w, w_attribute, w_embed) builder: Builder instance for loading tools attribute_search_fn: Optional pre-loaded attribute search function (will be loaded from config if None) critic_agent: Optional critic agent for result verification Yields: AgentMessageChunk for progress updates, then SearchOutput as final result """ decomposed: DecomposedQuery | None = None original_query = search_input.query if search_input.agent_mode and agent_llm: try: yield AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content=f"Decomposing query: '{search_input.query}'" ) # Fetch sensor names from VST based on source_type video_file_names: list[str] = [] video_stream_names: list[str] = [] try: vst_url = getattr(config, "vst_internal_url", None) if vst_url: streams_info = await get_streams_info(vst_url) source_type = getattr(search_input, "source_type", None) for _stream_id, stream_info in streams_info.items(): name = stream_info.get("name", "") url = stream_info.get("url", "") if not name: continue is_rtsp = url and url.startswith("rtsp://") if source_type == "rtsp" and is_rtsp: video_stream_names.append(name) elif source_type == "video_file" and not is_rtsp: video_file_names.append(name) elif source_type is None: if is_rtsp: video_stream_names.append(name) else: video_file_names.append(name) logger.info( f"Fetched sensor names from VST (source_type={source_type}): " f"{len(video_file_names)} video files, {len(video_stream_names)} streams" ) except (aiohttp.ClientError, TimeoutError) as e: logger.warning(f"Network error fetching sensor names from VST ({vst_url}): {e}") except (ValueError, KeyError, TypeError) as e: logger.warning(f"Failed to parse VST streams response: {e}") except Exception as e: logger.exception(f"Unexpected error fetching sensor names from VST: {e}") decomposed = await decompose_query( user_query=search_input.query, llm=agent_llm, video_file_names=video_file_names or None, video_stream_names=video_stream_names or None, ) if decomposed.query: search_input.query = decomposed.query if decomposed.video_sources: search_input.video_sources = decomposed.video_sources if decomposed.timestamp_start: try: search_input.timestamp_start = iso8601_to_datetime(decomposed.timestamp_start) except Exception as e: logger.warning(f"Failed to parse decomposed timestamp_start: {e}") if decomposed.timestamp_end: try: search_input.timestamp_end = iso8601_to_datetime(decomposed.timestamp_end) except Exception as e: logger.warning(f"Failed to parse decomposed timestamp_end: {e}") if decomposed.top_k is not None: search_input.top_k = decomposed.top_k if decomposed.min_cosine_similarity is not None: search_input.min_cosine_similarity = decomposed.min_cosine_similarity # Yield decomposition summary decomp_summary: dict[str, Any] = { "refined_query": decomposed.query or search_input.query, "attributes": decomposed.attributes or [], } if decomposed.timestamp_start: decomp_summary["timestamp_start"] = decomposed.timestamp_start if decomposed.timestamp_end: decomp_summary["timestamp_end"] = decomposed.timestamp_end if decomposed.top_k is not None: decomp_summary["top_k"] = decomposed.top_k yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content=f"Query decomposed: {json.dumps(decomp_summary)}", ) logger.info(f"Query decomposed: {decomposed.model_dump()}") except Exception as e: logger.warning(f"Query decomposition failed, using original query: {e}") yield AgentMessageChunk( type=AgentMessageChunkType.ERROR, content=f"Decomposition failed, using original query: {e!s}", ) # ===== SETUP COMMON QUERY PARAMETERS (used by all execution paths) ===== top_k = search_input.top_k if search_input.top_k is not None else config.default_max_results original_top_k = top_k top_k = top_k * 2 min_similarity = search_input.min_cosine_similarity # Build query_params for embed_search (used by embed-only and fusion paths) query_params: dict[str, str] = {"query": search_input.query} if search_input.video_sources and len(search_input.video_sources) > 0: query_params["video_sources"] = json.dumps(search_input.video_sources) if search_input.description: query_params["description"] = search_input.description if search_input.timestamp_start: query_params["timestamp_start"] = search_input.timestamp_start.isoformat() if search_input.timestamp_end: query_params["timestamp_end"] = search_input.timestamp_end.isoformat() query_params["min_cosine_similarity"] = str(search_input.min_cosine_similarity) # Extract attributes list and check if attribute-only (used by both attribute-only and fusion paths) attribute_list = [] is_attribute_only = False if search_input.agent_mode and agent_llm and decomposed and decomposed.attributes: attribute_list = decomposed.attributes # Prune single-word attributes (keep multi-word attributes even if connected with hyphens or dots) def _is_single_word(attr: str) -> bool: """Check if attribute is a single word (no spaces, hyphens, or dots).""" # Remove leading/trailing whitespace attr = attr.strip() # If it contains spaces, hyphens, or dots, it's multi-word return " " not in attr and "-" not in attr and "." not in attr original_count = len(attribute_list) attribute_list = [attr for attr in attribute_list if not _is_single_word(attr)] if len(attribute_list) < original_count: pruned_count = original_count - len(attribute_list) logger.info(f"Pruned {pruned_count} single-word attribute(s). Remaining attributes: {attribute_list}") logger.info(f"Extracted attributes: {attribute_list}") # Check if attribute-only: has_action=False means attribute-only, otherwise use fusion path # If has_action is None, and attributes exist, default to attribute-only if decomposed.has_action is not None: is_attribute_only = not decomposed.has_action elif attribute_list: # If has_action is None but attributes exist, treat as attribute-only is_attribute_only = True # ===== EXECUTION FLOW: Three distinct paths ===== search_results = [] do_search = True # Keep track of confirmed and rejected results to avoid re-running the critic agent on the known results rejected_results = set() confirmed_results = set() iteration_num = 0 while do_search and iteration_num < config.search_max_iterations: iteration_num += 1 do_search = False logger.info(f"[Search] Running embed search iteration {iteration_num}") # Use computed top_k (already defaults to config.default_max_results if None) query_params["top_k"] = str(top_k) query_input_json = json.dumps({"params": query_params, "source_type": search_input.source_type}) # PATH 1: Attribute-only search (attribute_list not empty AND is_attribute_only=True) logger.info( f"is_attribute_only: {is_attribute_only}, attribute_list: {attribute_list}, config.attribute_search_tool: {config.attribute_search_tool}" ) if is_attribute_only and attribute_list and config.attribute_search_tool: logger.info("EXECUTION PATH: Attribute-only search (no embed, append mode)") yield AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content=f"Running attribute-only search with {len(attribute_list)} attributes", ) # Load attribute_search tool if not provided if attribute_search_fn is None: attribute_search_fn = await builder.get_function(config.attribute_search_tool) # Use modular helper function search_results = await _run_attribute_only_search( attribute_list=attribute_list, search_input=search_input, attribute_search_fn=attribute_search_fn, top_k=original_top_k, min_similarity=min_similarity, ) yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content=f"Found {len(search_results)} results from attribute-only search", ) # PATH 2 & 3: Embed search first else: # Step 1: Run embed_search using query_input_json set up above (common for both paths) logger.info("EXECUTION PATH: Embed search") yield AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content=f"Running embed search with query: '{search_input.query}'" ) try: embed_search_output = await embed_search.ainvoke(query_input_json) except ValueError as e: error_msg = str(e) logger.error(f"Embed search failed: {error_msg}") yield AgentMessageChunk(type=AgentMessageChunkType.ERROR, content=f"Embed search failed: {error_msg}") raise HTTPException(status_code=404, detail=error_msg) from e except Exception as e: error_msg = str(e) status_code = 500 if hasattr(e, "status_code"): status_code = e.status_code elif hasattr(e, "meta") and hasattr(e.meta, "status"): status_code = e.meta.status elif len(e.args) > 0 and isinstance(e.args[0], int): status_code = e.args[0] logger.error(f"Unexpected error in embed search: {error_msg}", exc_info=True) yield AgentMessageChunk(type=AgentMessageChunkType.ERROR, content=f"Embed search failed: {error_msg}") raise HTTPException(status_code=status_code, detail=f"Search error: {error_msg}") from e if isinstance(embed_search_output, str): embed_output = EmbedSearchOutput.model_validate_json(embed_search_output) elif isinstance(embed_search_output, EmbedSearchOutput): embed_output = embed_search_output else: embed_output = EmbedSearchOutput.model_validate(embed_search_output) search_results = [] for item in embed_output.results: if not item.video_name: logger.warning("Skipping result with empty video_name") continue search_results.append( SearchResult( video_name=item.video_name, description=item.description, start_time=item.start_time, end_time=item.end_time, sensor_id=item.sensor_id, screenshot_url=item.screenshot_url, similarity=item.similarity_score, ) ) yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content=f"Found {len(search_results)} results from embed search", ) # Check embed confidence threshold: if all results below threshold, fallback to pure attribute search (like PATH 1) if search_results and attribute_list and config.attribute_search_tool: max_embed_score = max((r.similarity for r in search_results), default=0.0) if max_embed_score < config.embed_confidence_threshold: logger.info( f"Embed search confidence low (max_score={max_embed_score:.3f} < threshold={config.embed_confidence_threshold:.3f}). " f"Falling back to pure attribute-only search (like PATH 1)." ) yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content=f"Embed confidence low ({max_embed_score:.3f}), falling back to attribute-only search", ) # Load attribute_search tool if not provided if attribute_search_fn is None: attribute_search_fn = await builder.get_function(config.attribute_search_tool) # Fallback to pure attribute-only search (same as PATH 1) search_results = await _run_attribute_only_search( attribute_list=attribute_list, search_input=search_input, attribute_search_fn=attribute_search_fn, top_k=top_k, min_similarity=min_similarity, ) yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content=f"Found {len(search_results)} results from attribute-only search", ) # PATH 3 : If fusion search (embed confidence is HIGH and attribute_list exists), rerank results using fusion_search elif ( config.use_attribute_search and len(search_results) > 0 and max_embed_score >= config.embed_confidence_threshold # Only fuse if embed confidence is high ): try: logger.info("EXECUTION PATH: Fusion Search - Attribute search followed by Embed search") yield AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content=f"Running fusion reranking with attributes: {attribute_list}", ) # Load attribute_search tool if not provided if attribute_search_fn is None: attribute_search_fn = await builder.get_function(config.attribute_search_tool) # Call fusion_search utility to rerank results logger.info( f"Using {len(attribute_list)} LLM-extracted attributes for reranking: {attribute_list}" ) reranked_results = await fusion_search_rerank( embed_results=search_results, attributes=attribute_list, attribute_search_fn=attribute_search_fn, vst_internal_url=config.vst_internal_url, source_type=search_input.source_type, # Pass source_type for index selection fusion_method=config.fusion_method, rrf_k=config.rrf_k, rrf_w=config.rrf_w, w_attribute=config.w_attribute, w_embed=config.w_embed, ) # Use reranked results for critic verification if enabled search_results = reranked_results # Yield fusion completion message (success) yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content="Fusion reranking complete", ) except Exception as e: logger.error(f"Error in fusion_search reranking: {e}", exc_info=True) yield AgentMessageChunk( type=AgentMessageChunkType.ERROR, content=f"Fusion reranking failed, using embed results: {e!s}", ) # Fall through to return original embed_search results # Step 3: If critic enabled and configured, verify results with VLM if ( config.enable_critic and search_input.agent_mode and search_input.use_critic and critic_agent and search_results ): try: from vss_agents.agents.critic_agent import CriticAgentResult from vss_agents.agents.critic_agent import VideoInfo critic_results: dict[VideoInfo, CriticAgentResult] = {} yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content=f"Verifying {len(search_results)} results with critic agent", ) logger.info(f"[Search] Calling critic agent to verify {len(search_results)} results") # Call critic agent - use screenshot_url as video_url for critic search_videos: list[VideoInfo] = [] for result in search_results: info = VideoInfo( sensor_id=result.sensor_id, start_timestamp=result.start_time, end_timestamp=result.end_time, ) if info not in confirmed_results and info not in rejected_results: search_videos.append(info) if len(search_videos) > 0: critic_input = {"query": original_query, "videos": search_videos} logger.info(f"[Search] Critic agent input: {critic_input}") critic_output = await critic_agent.ainvoke(critic_input) logger.info(f"[Search] Critic output: {critic_output}") critic_results = {result.video_info: result.result for result in critic_output.video_results} for info, critic_result in critic_results.items(): match critic_result: case CriticAgentResult.CONFIRMED: confirmed_results.add(info) case CriticAgentResult.REJECTED: rejected_results.add(info) top_k += 1 do_search = True case CriticAgentResult.UNVERIFIED: logger.warning(f"[Search] Unverified result for video {info.sensor_id}") logger.info(f"[Search] rejected_results: {rejected_results}") # only filter the search_results directly if we are on the last iteration if iteration_num == config.search_max_iterations: filtered_search_results = [] for result in search_results: info = VideoInfo( sensor_id=result.sensor_id, start_timestamp=result.start_time, end_timestamp=result.end_time, ) # We may want to handle unverified results differently. For now, just assume they are confirmed. if info not in rejected_results: filtered_search_results.append(result) search_results = filtered_search_results # Yield critic results summary verified_count = sum(1 for result in critic_results.values() if result == CriticAgentResult.CONFIRMED) unverified_count = sum( 1 for result in critic_results.values() if result == CriticAgentResult.UNVERIFIED ) yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content=f"Critic verification complete: {verified_count}/{len(critic_results)} results verified, {unverified_count}/{len(critic_results)} results unverified", ) except Exception as e: logger.error(f"[Search] Error calling critic agent: {e}", exc_info=True) yield AgentMessageChunk(type=AgentMessageChunkType.THOUGHT, content=f"Critic verification failed: {e}") # Yield final results summary result_count = len(search_results) yield AgentMessageChunk( type=AgentMessageChunkType.THOUGHT, content=f"Found {result_count} result{'s' if result_count != 1 else ''}", ) # Yield final result, truncated to original top_k to undo any critic-loop inflation if original_top_k is not None: search_results = search_results[:original_top_k] yield SearchOutput(data=search_results) async def execute_core_search_wrapper( search_input: "SearchInput", embed_search: Any, agent_llm: Any | None, config: Any, builder: Builder, attribute_search_fn: Any | None = None, critic_agent: Any | None = None, ) -> "SearchOutput": """ Wrapper for execute_core_search that collects all progress updates and returns only the final result. Used by search.py for non-streaming search. """ async for update in execute_core_search( search_input=search_input, embed_search=embed_search, agent_llm=agent_llm, config=config, builder=builder, attribute_search_fn=attribute_search_fn, critic_agent=critic_agent, ): if isinstance(update, SearchOutput): return update # Ignore AgentMessageChunk updates (progress updates) for non-streaming mode # Should never reach here, but return empty result if we do return SearchOutput(data=[]) class SearchConfig(FunctionBaseConfig, name="search"): """Configuration for the Search tool.""" embed_search_tool: FunctionRef = Field( ..., description="The function reference of the embed search tool to use.", ) attribute_search_tool: FunctionRef | None = Field( default=None, description="Optional: The function reference of the attribute search tool. Used for fusion reranking when use_attribute_search is enabled.", ) embed_confidence_threshold: float = Field( default=0.2, description="Minimum embed search similarity threshold. If all embed results are below this threshold, fallback to attribute-only search (if attributes exist).", ) agent_mode_llm: LLMRef = Field( ..., description="The name of the LLM to use for the search tool to analyze/decompose the input query and fill in parameters if agent_mode is True", ) agent_mode_prompt: str = Field( default=QUERY_DECOMPOSITION_PROMPT, description="Prompt for the agent(LLM) to analyze/decompose the input query and fill in parameters if agent_mode is True", ) use_attribute_search: bool = Field( default=False, description="If True and attribute_search_tool is configured, performs multi-attribute object-level search using extracted attributes from query decomposition. Requires agent_mode=True. (internal config, not exposed to user)", ) vst_internal_url: str = Field( ..., description="The internal VST URL for stream_id to sensor_id conversion in fusion reranking.", ) critic_agent: FunctionRef | None = Field( default=None, description="""Optional critic agent to verify search results with VLM. The critic agent will remove any results that do not match the query. Requires agent_mode=True.""", ) default_max_results: int = Field( default=10, description="Maximum number of results to return. Used as the default top_k when not specified and as a cap when top_k is too high.", ) enable_critic: bool = Field( default=False, description="Configuration flag to enable/disable critic agent at a global level.", ) search_max_iterations: int = Field( default=1, ge=1, description="""Maximum number of search iterations when refining search results with critic agent. Note, high max iterations can run for a long time. Default is 1.""", ) fusion_method: Literal["weighted_linear", "rrf"] = Field( default="rrf", description="Fusion method: 'weighted_linear' for weighted linear fusion, 'rrf' for Reciprocal Rank Fusion", ) w_attribute: float = Field( default=0.55, description="Weight for attribute score in weighted linear fusion (default: 0.55)", ) w_embed: float = Field( default=0.35, description="Weight for embed score in weighted linear fusion (default: 0.35)", ) rrf_k: int = Field( default=60, description="RRF constant k for Reciprocal Rank Fusion (default: 60, only used for RRF)", ) rrf_w: float = Field( default=0.5, description="RRF weight w for attribute cosine similarity in Reciprocal Rank Fusion (default: 0.5, only used for RRF)", ) class SearchInput(BaseModel): """Input for the Search tool""" model_config = ConfigDict(extra="forbid") query: str = Field( ..., description="Description of the item to search from", ) source_type: Literal["rtsp", "video_file"] = Field( ..., description="Type of video source: 'rtsp' for live streams or 'video_file' for uploaded video files.", ) video_sources: list[str] | None = Field( default=None, description="A list of video names to search from. In DevEx, these are VST sensor-names. Defaults to search from all videos.", ) description: str | None = Field( default=None, description="Description of video's metadata data, for example, the location of the camera, the category of videos. Defaults to match all descriptions.", ) timestamp_start: datetime | None = Field( default=None, description="Start time of the video, ISO timestamp. Note for uploaded videos, as a convention, we use 2025-01-01T00:00:00 as the start time.", ) timestamp_end: datetime | None = Field( default=None, description="End time of the video, ISO timestamp. Note for uploaded videos, as a convention, we use 2025-01-01T00:00:00 as the start time.", ) top_k: int | None = Field( default=None, description="Number of returned videos. If not provided, returns all matching results.", ) min_cosine_similarity: float = Field( default=0.0, description="Minimum cosine similarity to filter the results. Default is 0.", ) agent_mode: bool = Field( ..., description="Whether or not backend shall use an agent(LLM) to analyze/decompose the input query and fill in parameters", ) use_critic: bool = Field( default=True, description="""Request-level flag to enable/disable critic agent for this search request. `critic_agent` must be set and `enable_critic` must be True in the config.""", ) # FIXME: sensor_id is not the same as stream_id, but for now they have the same value. # We'll need to revisit this code once we begin to differentiate between them. class SearchResult(BaseModel): """A single search result item""" video_name: str = Field(..., description="Name of the video") description: str = Field(..., description="Description of the video") start_time: str = Field(..., description="Start time of the video in ISO timestamp format") end_time: str = Field(..., description="End time of the video in ISO timestamp format") sensor_id: str = Field(..., description="Sensor ID (e.g., 21908c9a-bd40-4941-8a2e-79bc0880fb5a)") screenshot_url: str = Field(..., description="URL to access the screenshot") similarity: float = Field(..., description="Cosine similarity score") object_ids: list[str] = Field( default_factory=list, description="List of object IDs for video generation (from attribute search)" ) class SearchOutput(BaseModel): """Output for the Search tool""" model_config = ConfigDict(extra="forbid") data: list[SearchResult] = Field( default_factory=list, description="List of search results matching the query", ) @register_function(config_type=SearchConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def search(config: SearchConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: embed_search = await _builder.get_function(config.embed_search_tool) agent_llm = None if config.agent_mode_prompt: agent_llm = await _builder.get_llm(config.agent_mode_llm, wrapper_type=LLMFrameworkEnum.LANGCHAIN) # Get critic agent if configured critic_agent = None if config.critic_agent: critic_agent = await _builder.get_function(config.critic_agent) async def _search(search_input: SearchInput) -> SearchOutput: """ Search for videos based on a query with optional filters. Input: search_input: SearchInput Returns: SearchOutput: Search results matching the query. """ # Use shared core search function (wrapper that collects results) return await execute_core_search_wrapper( search_input=search_input, embed_search=embed_search, agent_llm=agent_llm, config=config, builder=_builder, attribute_search_fn=None, # Will be loaded from config if needed critic_agent=critic_agent, ) def _str_input_converter(input: str) -> SearchInput: logger.info(f"String input: {input}") return SearchInput.model_validate_json(input) def _chat_request_input_converter(request: ChatRequest) -> SearchInput: try: logger.info(f"Chat request input content: {request.messages[-1].content}") logger.info(f"Chat request input content type: {type(request.messages[-1].content)}") return SearchInput.model_validate_json(request.messages[-1].content) except Exception: logger.exception("Error in chat request input converter.") raise def _output_converter(output: SearchOutput) -> str: logger.info(f"Output: {output}") return output.model_dump_json() def _chat_response_output_converter(response: SearchOutput) -> ChatResponse: logger.info(f"Chat response output: {response}") return ChatResponse.from_string(_output_converter(response), usage=Usage()) def _chat_response_chunk_output_converter(response: SearchOutput) -> ChatResponseChunk: logger.info(f"Chat response chunk output: {response}") return ChatResponseChunk.from_string(_output_converter(response)) yield FunctionInfo.create( single_fn=_search, description=_search.__doc__, input_schema=SearchInput, single_output_schema=SearchOutput, converters=[ _str_input_converter, _chat_request_input_converter, _output_converter, _chat_response_output_converter, _chat_response_chunk_output_converter, ], ) ================================================ FILE: agent/src/vss_agents/tools/template_report_gen.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio from collections.abc import AsyncGenerator from datetime import datetime from importlib.resources import files import json import logging import os from pathlib import Path import re import tempfile from typing import Any try: import markdown from xhtml2pdf import pisa PDF_CONVERSION_AVAILABLE = True except ImportError: PDF_CONVERSION_AVAILABLE = False from langchain_core.prompts import ChatPromptTemplate from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.component_ref import ObjectStoreRef from nat.data_models.function import FunctionBaseConfig from nat.object_store.models import ObjectStoreItem from pydantic import BaseModel from pydantic import Field from vss_agents.tools.video_understanding import extend_timestamp from vss_agents.utils.reasoning_utils import get_llm_reasoning_bind_kwargs from vss_agents.utils.reasoning_utils import get_thinking_tag logger = logging.getLogger(__name__) def _get_object_store_url(object_store: Any, filename: str, config: "TemplateReportGenConfig") -> str: """ Get HTTP URL for a file from any object store type. Supports: - S3/MinIO object store (construct URL from endpoint) - in_memory and other stores (use NAT file server /static/ endpoint) Args: object_store: The object store instance filename: The file key/name config: The template report gen config Returns: str: HTTP URL to access the file """ # S3/MinIO object store - construct URL from attributes if hasattr(object_store, "endpoint_url") and hasattr(object_store, "bucket_name"): endpoint = object_store.endpoint_url bucket = object_store.bucket_name # Remove trailing slash from endpoint endpoint = endpoint.rstrip("/") return f"{endpoint}/{bucket}/{filename}" # For in_memory and other stores - use NAT's /static/ endpoint from config # The file server is configured via general.front_end.object_store # Remove trailing slash and construct URL base_url = config.base_url.rstrip("/") return f"{base_url}/{filename}" def _replace_public_urls_with_private( markdown_content: str, vst_internal_url: str | None, vst_external_url: str | None ) -> str: """ Replace external (public) URLs in markdown image tags with internal (private) IP URLs for PDF generation. Handles markdown format: ![alt](url) Args: markdown_content: Markdown content with image URLs vst_internal_url: Internal VST URL (e.g., 'http://10.0.0.1:30888') - private IP for PDF vst_external_url: External VST URL (e.g., 'http://public.example.com:30888') - public URL to replace Returns: Markdown content with image URLs updated to use private IP """ if not vst_internal_url or not vst_external_url: logger.debug( f"URL replacement skipped - vst_internal_url: {vst_internal_url is not None}, " f"vst_external_url: {vst_external_url is not None}" ) return markdown_content # Extract base URLs (scheme + host + port) internal_match = re.match(r"(https?://[^/]+)", vst_internal_url) external_match = re.match(r"(https?://[^/]+)", vst_external_url) if not internal_match or not external_match: logger.warning(f"Could not parse URLs - internal: {vst_internal_url}, external: {vst_external_url}") return markdown_content internal_base = internal_match.group(1) # e.g., 'http://10.0.0.1:30888' external_base = external_match.group(1) # e.g., 'http://203.0.113.1:30888' logger.info( f"Replacing external URL '{external_base}' with internal URL '{internal_base}' in markdown image URLs for PDF" ) # Replace URLs in markdown image format: ![alt](URL) def replace_md_img(match: re.Match[str]) -> str: full_match = match.group(0) url = match.group(2) # Replace external base with internal base if found if external_base in url: new_url = url.replace(external_base, internal_base) logger.debug(f"Replacing image URL: {url} -> {new_url}") return full_match.replace(url, new_url) return full_match # Replace in ![alt](url) format - the classic markdown format result = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", replace_md_img, markdown_content) logger.info("URL replacement completed for template report PDF generation") return result class TemplateReportGenConfig(FunctionBaseConfig, name="template_report_gen"): """Configuration for the template report generation tool.""" object_store: ObjectStoreRef = Field(description="Reference to the object store for serving files via HTTP") base_url: str = Field( default="http://localhost:8000/static", description="Base URL for file server (used for in_memory and other non-S3 object stores). Should end with /static for NAT file server.", ) template_path: str | None = Field( default="", description="Path to template (relative to project root), if not provided, it will skip the template formatting and use the output from VLM directly for the port", ) output_dir: str = Field( default="./agent_reports", description="Base directory for local copies. Reports will be saved in {output_dir}/{sensor_id}/ subdirectories", ) save_local_copy: bool = Field( default=False, description="Whether to also save a local copy of the report files organized by sensor_id", ) use_sensor_id_prefix_for_object_store_path: bool = Field( default=False, description="Whether to prefix the object store path with sensor_id", ) llm_name: str = Field( default="", description="Name of the LLM to use for custom report generation (required when template_type='custom')", ) template_name: str | None = Field( default=None, description="Name of the main template file to use for custom reports, if not provided, it will skip the template formatting and use the output from VLM directly for the port", ) agent_version: str = Field( default="v1.0.0", description="Version of the AI agent to include in the report", ) video_understanding_tool: str = Field( default="", description="Name of the video understanding tool to use for custom report generation (required when template_type='custom')", ) vlm_prompts: list[str] = Field( default=[], description="List of prompts to query the VLM for video understanding", ) report_prompt: str = Field( default="", description="System prompt for the LLM to use when generating custom reports. Must contain {template} for the report template. ", ) include_picture_url: bool = Field( default=True, description="Whether to include the picture URL in the report", ) picture_url_tool: FunctionRef = Field( default="vst_picture_url", description="A tool to be used to get the picture URL by sensor ID and timestamp(default to use VST service)", ) video_url_tool: str | None = Field( default=None, description="A tool to be used to get the video URL by sensor ID and timestamp, only required if we use VST for media storage", ) geolocation_tool: FunctionRef | None = Field( default=None, description="A tool to fetch geolocation information from latitude and longitude coordinates", ) vst_internal_url: str | None = Field( default=None, description="Internal VST URL for API calls (e.g., 'http://${INTERNAL_IP}:30888'). Used for PDF generation with private IPs.", ) vst_external_url: str | None = Field( default=None, description="External VST URL for client-facing URLs (e.g., 'http://${EXTERNAL_IP}:30888'). Used to identify URLs to replace in PDFs.", ) class TemplateReportGenInput(BaseModel): """Input for the report generation tool.""" alert_sensor_id: str = Field(..., description="Sensor ID for which alerts are requested") alert_from_timestamp: str = Field(..., description="Start timestamp in ISO format") alert_to_timestamp: str = Field(..., description="End timestamp in ISO format") alert_metadata: dict = Field(..., description="Metadata for the alert") vlm_reasoning: bool | None = Field(None, description="Enable VLM reasoning mode for video analysis") llm_reasoning: bool | None = Field(None, description="Enable LLM reasoning mode for report generation") class TemplateReportGenOutput(BaseModel): """Output from the report generation tool.""" http_url: str = Field(..., description="HTTP URL to access the markdown report file") pdf_url: str = Field(..., description="HTTP URL to access the PDF report file") object_store_key: str = Field(..., description="Key/filename in the object store") summary: str = Field(..., description="Brief summary of the report") file_size: int = Field(..., description="Size of the markdown report file in bytes") pdf_file_size: int = Field(..., description="Size of the PDF report file in bytes") content: str = Field(..., description="The actual markdown content of the generated report") image_url: str = Field(..., description="The URL of the image") video_url: str | None = Field(None, description="The URL of the video") def _convert_markdown_to_pdf(markdown_file_path: str, output_pdf_path: str) -> bool: """Convert markdown file to PDF using Python packages.""" if not PDF_CONVERSION_AVAILABLE: logger.warning("PDF conversion not available. Install 'markdown' and 'xhtml2pdf' packages.") return False try: # Read markdown file with open(markdown_file_path, encoding="utf-8") as f: markdown_content = f.read() # Convert markdown to HTML html_content = markdown.markdown(markdown_content, extensions=["tables", "fenced_code"]) # Add professional CSS styling with NVIDIA branding for better PDF appearance styled_html = f""" {html_content} """ # Convert HTML to PDF using xhtml2pdf with open(output_pdf_path, "wb") as pdf_file: pisa_status = pisa.CreatePDF(styled_html, dest=pdf_file) if pisa_status.err: logger.error(f"PDF conversion had errors: {pisa_status.err}") return False logger.info(f"Successfully converted markdown to PDF: {output_pdf_path}") return True except Exception as e: logger.error(f"Error converting markdown to PDF: {e}") return False def _load_custom_template(template_path: str, template_name: str) -> str: """Load a custom template from the specified path.""" # Check if this is a package resource path (e.g., "warehouse_report:templates") if ":" in template_path: package_name, resource_dir = template_path.split(":", 1) try: package_files = files(package_name) resource_path = f"{resource_dir}/{template_name}" if resource_dir else template_name return (package_files / resource_path).read_text() except Exception as e: logger.error(f"Failed to load template {template_name} from package {package_name}: {e}") return f"# Report\n\nTemplate '{template_name}' could not be loaded from package '{package_name}'.\n\nError: {e}\n\n" else: # Regular file path full_template_path = Path(template_path) / template_name try: with open(full_template_path, encoding="utf-8") as f: return f.read() except Exception as e: logger.error(f"Failed to load custom template {template_name} from {template_path}: {e}") return ( f"# Report\n\nTemplate '{template_name}' could not be loaded from '{template_path}'.\n\nError: {e}\n\n" ) async def _fetch_cv_metadata( report_input: TemplateReportGenInput, behavior_tool: Any | None, ) -> str: """Fetch CV metadata (behavior data) and add counts to alert metadata.""" cv_metadata_str = "" behavior_data_result = None if behavior_tool: behavior_data_result = await _fetch_behavior_data( behavior_tool, report_input.alert_sensor_id, report_input.alert_from_timestamp, report_input.alert_to_timestamp, ) cv_metadata_str = behavior_data_result["cv_metadata"] logger.info(f"CV metadata fetched: {cv_metadata_str[:200]}...") # Add people and vehicle counts to alert metadata report_input.alert_metadata["people_count"] = behavior_data_result["people_count"] report_input.alert_metadata["vehicle_count"] = behavior_data_result["vehicle_count"] return cv_metadata_str async def _fetch_proximity_data( report_input: TemplateReportGenInput, frames_enhanced_tool: Any | None, ) -> float | None: """Fetch proximity threshold and add to alert metadata.""" proximity_threshold = None if frames_enhanced_tool: proximity_threshold = await _fetch_proximity_threshold( frames_enhanced_tool, report_input.alert_sensor_id, report_input.alert_from_timestamp, report_input.alert_to_timestamp, ) return proximity_threshold async def _fetch_geolocation_data( report_input: TemplateReportGenInput, geolocation_tool: Any | None, ) -> dict[str, Any]: """Extract location from alert metadata and fetch geolocation information.""" geolocation_data: dict[str, Any] = {} if not geolocation_tool: logger.warning("Geolocation tool not configured, skipping geolocation data fetch") return geolocation_data try: location_str = report_input.alert_metadata.get("info", {}).get("location") if not location_str: logger.warning( f"No location information found in alert metadata. Info field: {report_input.alert_metadata.get('info')}" ) return geolocation_data # Parse the location string "latitude,longitude,elevation" to extract latitude and longitude location_parts = location_str.split(",") if len(location_parts) < 2: logger.warning(f"Invalid location format: {location_str}") return geolocation_data latitude = float(location_parts[0]) longitude = float(location_parts[1]) logger.info(f"latitude: {latitude}, longitude: {longitude}") geo_result = await geolocation_tool.ainvoke( input={ "latitude": latitude, "longitude": longitude, } ) geolocation_data = geo_result.model_dump() logger.info(f"Geolocation data: {geolocation_data}") except Exception as e: logger.error(f"Failed to fetch geolocation data: {e}", exc_info=True) return geolocation_data def _extract_object_ids_from_incident(alert_metadata: dict) -> list[str]: """ Extract object IDs from incident metadata. Looks for: - objectIds field (list of IDs) - info.primaryObjectId field (single ID) Args: alert_metadata: The incident metadata dictionary Returns: List of unique object IDs as strings """ object_ids = set() # Extract from objectIds field if alert_metadata.get("objectIds"): if isinstance(alert_metadata["objectIds"], list): object_ids.update(alert_metadata["objectIds"]) else: object_ids.add(alert_metadata["objectIds"]) # Extract from info.primaryObjectId field if "info" in alert_metadata and isinstance(alert_metadata["info"], dict): primary_id = alert_metadata["info"].get("primaryObjectId") if primary_id is not None: object_ids.add(primary_id) result = [str(oid) for oid in object_ids if oid is not None] logger.info(f"Extracted object IDs from incident: {result}") return result async def _run_vlm_analysis( report_input: TemplateReportGenInput, vlm_tool: Any, config: TemplateReportGenConfig, object_ids: list[str] | None = None, ) -> list[str]: """Run VLM analysis tasks on the video.""" vlm_tasks = [] for vlm_prompt in config.vlm_prompts: logger.info(f"Running VLM task for prompt: {vlm_prompt}") # Format prompt with object_ids if the placeholder exists enhanced_prompt = vlm_prompt if "{object_ids}" in vlm_prompt and object_ids: object_ids_str = ", ".join(object_ids) enhanced_prompt = vlm_prompt.replace("{object_ids}", object_ids_str) vlm_input: dict[str, Any] = { "sensor_id": report_input.alert_sensor_id, "user_prompt": enhanced_prompt, "start_timestamp": report_input.alert_from_timestamp, "end_timestamp": report_input.alert_to_timestamp, } # Add object_ids for video overlay (always pass if available) if object_ids: vlm_input["object_ids"] = object_ids # Add vlm_reasoning if specified if report_input.vlm_reasoning is not None: vlm_input["vlm_reasoning"] = report_input.vlm_reasoning vlm_tasks.append(vlm_tool.ainvoke(input=vlm_input)) vlm_results = await asyncio.gather(*vlm_tasks) logger.debug(f"VLM results: {vlm_results}") return vlm_results async def _fetch_media_urls_for_report( report_input: TemplateReportGenInput, picture_url_tool: Any, video_url_tool: Any | None, config: TemplateReportGenConfig, object_ids: list[str] | None = None, ) -> tuple[str, str | None]: """Fetch picture and video URLs for the report.""" picture_url_results = await picture_url_tool.ainvoke( input={ "sensor_id": report_input.alert_sensor_id, "start_time": report_input.alert_from_timestamp, } ) logger.info(f"Picture URL results: {picture_url_results.image_url}") # Determine video URL based on the tool being used video_url = None if "s3" in config.picture_url_tool: video_url = picture_url_results.video_url logger.info(f"Video URL from S3: {video_url}") elif video_url_tool is not None: logger.info(f"Using video URL tool to get video URL: {config.video_url_tool}") extended_end_time = extend_timestamp(report_input.alert_from_timestamp, report_input.alert_to_timestamp) video_url_input: dict[str, Any] = { "sensor_id": report_input.alert_sensor_id, "start_time": report_input.alert_from_timestamp, "end_time": extended_end_time, } # Add object_ids if provided if object_ids: video_url_input["object_ids"] = object_ids logger.info(f"Passing object IDs to video URL tool: {object_ids}") video_url_results = await video_url_tool.ainvoke(input=video_url_input) video_url = video_url_results.video_url logger.info(f"Video URL from VST: {video_url}") return picture_url_results.image_url, video_url async def _save_markdown_to_object_store( markdown_content: str, filename: str, object_store: Any, config: TemplateReportGenConfig, sensor_id: str = "", ) -> tuple[str, int]: """Save markdown content to object store.""" content_bytes = markdown_content.encode("utf-8") file_size = len(content_bytes) timestamp = datetime.now() metadata = { "timestamp": timestamp.strftime("%Y%m%d_%H%M%S"), "generated_at": timestamp.isoformat(), "file_size": str(file_size), "content_type": "text/markdown", } # Include sensor_id prefix in object store key if provided object_store_key = f"{sensor_id}/{filename}" if sensor_id else filename object_store_item = ObjectStoreItem(data=content_bytes, content_type="text/markdown", metadata=metadata) await object_store.upsert_object(object_store_key, object_store_item) logger.info(f"Markdown report saved to object store: {object_store_key}") # Get HTTP URL using universal method http_url = _get_object_store_url(object_store, object_store_key, config) return http_url, file_size async def _save_pdf_to_object_store( markdown_content: str, filename: str, pdf_filename: str, object_store: Any, config: TemplateReportGenConfig, sensor_id: str = "", ) -> tuple[str, int]: """Generate PDF from markdown and save to object store. Returns URL and size.""" pdf_file_size = 0 pdf_url = "" with tempfile.TemporaryDirectory() as temp_dir: temp_md_path = os.path.join(temp_dir, filename) temp_pdf_path = os.path.join(temp_dir, pdf_filename) # Replace public URLs with private IPs for image URLs before PDF generation pdf_markdown_content = _replace_public_urls_with_private( markdown_content, config.vst_internal_url, config.vst_external_url ) # Log the complete markdown content before saving to temp file logger.debug("=" * 80) logger.debug("MARKDOWN CONTENT BEFORE PDF GENERATION (with internal IPs)") logger.debug("=" * 80) logger.debug(pdf_markdown_content) logger.debug("=" * 80) logger.debug("END OF MARKDOWN CONTENT") logger.debug("=" * 80) # Write markdown to temp file and convert to PDF with open(temp_md_path, "w", encoding="utf-8") as f: f.write(pdf_markdown_content) if _convert_markdown_to_pdf(temp_md_path, temp_pdf_path): with open(temp_pdf_path, "rb") as f: pdf_bytes = f.read() pdf_file_size = len(pdf_bytes) timestamp = datetime.now() pdf_object_store_item = ObjectStoreItem( data=pdf_bytes, content_type="application/pdf", metadata={ "timestamp": timestamp.strftime("%Y%m%d_%H%M%S"), "generated_at": timestamp.isoformat(), "file_size": str(pdf_file_size), "content_type": "application/pdf", }, ) # Include sensor_id prefix in object store key if provided pdf_object_store_key = f"{sensor_id}/{pdf_filename}" if sensor_id else pdf_filename await object_store.upsert_object(pdf_object_store_key, pdf_object_store_item) # Get HTTP URL using universal method pdf_url = _get_object_store_url(object_store, pdf_object_store_key, config) logger.info(f"PDF report saved to object store: {pdf_object_store_key}") else: logger.warning("Failed to generate PDF report") return pdf_url, pdf_file_size async def _fetch_behavior_data( behavior_tool: Any, sensor_id: str, from_timestamp: str, to_timestamp: str, ) -> dict[str, Any]: """Fetch behavior data for people and vehicles. Args: behavior_tool: The behavior MCP tool sensor_id: Sensor ID to query from_timestamp: Start timestamp in ISO format to_timestamp: End timestamp in ISO format Returns: Dictionary with 'people_count', 'vehicle_count', and 'cv_metadata' (raw API response as JSON string) """ try: logger.info("Fetching behavior data") behavior_results = await behavior_tool.ainvoke( input={ "sensorId": sensor_id, "place": "", "objectId": "", "objectType": "", "fromTimestamp": from_timestamp, "toTimestamp": to_timestamp, "queryString": "", } ) logger.debug(f"Behavior results received: {behavior_results}") if isinstance(behavior_results, str): behavior_data = json.loads(behavior_results) else: behavior_data = behavior_results # Count unique objects by type people_ids = set() vehicle_ids = set() if behavior_data.get("behaviors"): for behavior in behavior_data["behaviors"]: if behavior.get("object"): obj = behavior["object"] obj_id = obj.get("id") obj_type = obj.get("type", "Unknown") if obj_id: if obj_type.lower() == "person": people_ids.add(obj_id) else: # Everything non-person is considered a vehicle vehicle_ids.add(obj_id) people_count = len(people_ids) vehicle_count = len(vehicle_ids) # Convert the entire API response to a formatted JSON string for the VLM cv_metadata_str = json.dumps(behavior_data, indent=2) logger.info(f"Counted {people_count} people and {vehicle_count} vehicles") return { "people_count": people_count, "vehicle_count": vehicle_count, "cv_metadata": cv_metadata_str, } except Exception as e: logger.warning(f"Failed to fetch behavior data: {e}") return { "people_count": 0, "vehicle_count": 0, "cv_metadata": "No CV metadata available", } async def _fetch_proximity_threshold( frames_enhanced_tool: Any, sensor_id: str, from_timestamp: str, to_timestamp: str, ) -> float | None: """Fetch proximity detection threshold from enhanced frame analytics. Args: frames_enhanced_tool: The frames enhanced MCP tool sensor_id: Sensor ID to query from_timestamp: Start timestamp in ISO format to_timestamp: End timestamp in ISO format Returns: Proximity threshold value (in meters) if found, None otherwise """ try: logger.info("Fetching enhanced frame data for proximity threshold") frames_enhanced_results = await frames_enhanced_tool.ainvoke( input={ "sensorId": sensor_id, "fromTimestamp": from_timestamp, "toTimestamp": to_timestamp, "maxResultSize": 25, } ) logger.info(f"Frames enhanced results: {frames_enhanced_results}") if isinstance(frames_enhanced_results, str): frames_data = json.loads(frames_enhanced_results) else: frames_data = frames_enhanced_results if frames_data.get("enhancedFrames"): for frame in frames_data["enhancedFrames"]: if frame.get("socialDistancing"): proximity_threshold = frame["socialDistancing"].get("threshold") if proximity_threshold is not None: logger.info(f"Extracted proximity threshold: {proximity_threshold}") return float(proximity_threshold) logger.warning("No proximity threshold found in enhanced frames") return None except Exception as e: logger.warning(f"Failed to fetch proximity data from frames_enhanced: {e}") return None async def _format_custom_report( vlm_results: list[str], alert_metadata: dict[str, Any], alert_sensor_id: str, alert_from_timestamp: str, alert_to_timestamp: str, template_path: str, template_name: str, report_prompt: str, llm: Any, image_url: str | None = None, video_url: str | None = None, agent_version: str = "v1.0.0", llm_reasoning: bool | None = None, ) -> str: """Format custom report using LLM to extract information from messages and populate template.""" try: template_content = _load_custom_template(template_path, template_name) # Substitute the template into the report_prompt, but escape template placeholders # so they don't get treated as prompt variables escaped_template = template_content.replace("{", "{{").replace("}", "}}") formatted_system_prompt = report_prompt.format(template=escaped_template, agent_version=agent_version) # Append thinking tag to system prompt if applicable thinking_tag = get_thinking_tag(llm, llm_reasoning) if thinking_tag: formatted_system_prompt = f"{formatted_system_prompt}\n{thinking_tag}" prompt_template = ChatPromptTemplate.from_messages( [ ("system", formatted_system_prompt), ( "user", "Video understanding results:\n\n{vlm_results}, alert metadata:\n\n{alert_metadata}, alert sensor ID:\n\n{alert_sensor_id}, alert from timestamp:\n\n{alert_from_timestamp}, alert to timestamp:\n\n{alert_to_timestamp}", ), ], ) # Bind LLM with reasoning kwargs if applicable llm_kwargs = get_llm_reasoning_bind_kwargs(llm, llm_reasoning) if llm_kwargs: llm = llm.bind(**llm_kwargs) chain = prompt_template | llm response = await chain.ainvoke( { "vlm_results": vlm_results, "alert_metadata": alert_metadata, "alert_sensor_id": alert_sensor_id, "alert_from_timestamp": alert_from_timestamp, "alert_to_timestamp": alert_to_timestamp, } ) content: str = str(response.content).strip() # Remove markdown code blocks if present if content.startswith("```markdown"): content = content[11:-3] elif content.startswith("```"): content = content[3:-3] except Exception: logger.info("no template specified, using VLM results directly") content = "\n".join(vlm_results) content = content.removeprefix("```markdown\n").removeprefix("```") content = content.removesuffix("```").strip() try: # Find the end of the tag and keep everything after it match = re.search(r"", content, flags=re.IGNORECASE) if match: content = content[match.end() :] content = content.strip() # Append actual URLs to the end of the content if image_url or video_url: content += "\n\n##Resources\n\n" if image_url: content += f"**Incident Snapshot:** ![Incident Snapshot]({image_url})\n\n" if video_url: # FIX: URL is placed in its own paragraph (\n\n) so text-align:justify # does not stretch the space between the label and URL in the PDF. content += f"**Incident Video:**\n\n{video_url}\n\n" return content except Exception as e: logger.error(f"Error generating custom report with LLM: {e}") return f"Error generating custom report with LLM, {e}" @register_function(config_type=TemplateReportGenConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def template_report_gen(config: TemplateReportGenConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """Tool for generating a report using a template, saving it to an object store, and providing HTTP URLs for easy access.""" # Get the object store client object_store = await builder.get_object_store_client(config.object_store) vlm_tool = await builder.get_tool(config.video_understanding_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) picture_url_tool = await builder.get_tool(config.picture_url_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) video_url_tool = None if "s3" not in config.picture_url_tool and config.video_url_tool is not None: video_url_tool = await builder.get_tool(config.video_url_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) geolocation_tool = None if config.geolocation_tool: geolocation_tool = await builder.get_tool(config.geolocation_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) async def _template_report_gen(report_input: TemplateReportGenInput) -> TemplateReportGenOutput: """ This tool generates a report using a template, saves it to an object store, and provides HTTP URLs for easy access. It can also optionally save local copies. """ if not config.llm_name: raise ValueError("llm_name must be configured when template_type='custom'") logger.info(f"Input: {report_input}") # Extract object IDs from incident metadata object_ids = _extract_object_ids_from_incident(report_input.alert_metadata) # Fetch geolocation data geolocation_data = await _fetch_geolocation_data(report_input, geolocation_tool) report_input.alert_metadata["geolocation"] = geolocation_data # Run VLM analysis on video try: vlm_results = await _run_vlm_analysis(report_input, vlm_tool, config, object_ids) except Exception as e: raise ValueError(f"Failed to run VLM analysis: {e}") from e # Fetch picture and video URLs image_url, video_url = await _fetch_media_urls_for_report( report_input, picture_url_tool, video_url_tool, config, object_ids ) # Format the report using LLM if not config.template_path or not config.template_name: raise ValueError("template_path and template_name are required for template report generation") markdown_content = await _format_custom_report( vlm_results=vlm_results, alert_metadata=report_input.alert_metadata, alert_sensor_id=report_input.alert_sensor_id, alert_from_timestamp=report_input.alert_from_timestamp, alert_to_timestamp=report_input.alert_to_timestamp, template_path=config.template_path, template_name=config.template_name, report_prompt=config.report_prompt, llm=llm, image_url=image_url, video_url=video_url, agent_version=config.agent_version, llm_reasoning=report_input.llm_reasoning, ) # Generate filenames timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"agent_report_{timestamp}.md" pdf_filename = filename.replace(".md", ".pdf") # Extract sensor_id for object store path prefix sensor_id = report_input.alert_sensor_id.lower() if config.use_sensor_id_prefix_for_object_store_path else "" # Save markdown to object store http_url, file_size = await _save_markdown_to_object_store( markdown_content, filename, object_store, config, sensor_id ) # Generate and save PDF to object store pdf_url, pdf_file_size = await _save_pdf_to_object_store( markdown_content, filename, pdf_filename, object_store, config, sensor_id ) # Save local copies local_md_path = "" local_pdf_path = "" if config.save_local_copy: # Create sensor-specific directory local_dir = os.path.join(config.output_dir, sensor_id) Path(local_dir).mkdir(parents=True, exist_ok=True) # Save markdown file locally local_md_path = os.path.join(local_dir, filename) with open(local_md_path, "w", encoding="utf-8") as f: f.write(markdown_content) logger.info(f"Local markdown report saved to: {local_md_path}") # Save PDF file locally if it was generated if pdf_url and pdf_file_size > 0: local_pdf_path = os.path.join(local_dir, pdf_filename) if _convert_markdown_to_pdf(local_md_path, local_pdf_path): logger.info(f"Local PDF report saved to: {local_pdf_path}") else: logger.warning("Failed to save local PDF copy") # Create summary logger.info(f"Report saved to object store and available at: {http_url}") if pdf_url: logger.info(f"PDF report available at: {pdf_url}") summary = f"Report saved successfully. \nMarkdown: {http_url}" + (f"\nPDF: {pdf_url}" if pdf_url else "") return TemplateReportGenOutput( http_url=http_url, pdf_url=pdf_url, object_store_key=filename, summary=summary, file_size=file_size, pdf_file_size=pdf_file_size, content=markdown_content, image_url=image_url, video_url=video_url, ) function_info = FunctionInfo.create( single_fn=_template_report_gen, description=_template_report_gen.__doc__, input_schema=TemplateReportGenInput, single_output_schema=TemplateReportGenOutput, ) yield function_info ================================================ FILE: agent/src/vss_agents/tools/video_caption.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio from collections.abc import AsyncGenerator import logging import os import shutil from typing import Any import uuid import httpx from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.component_ref import LLMRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from pydantic import model_validator from vss_agents.utils.file_mapping import resolve_video_file from vss_agents.utils.time_measure import TimeMeasure logger = logging.getLogger(__name__) VLM_PROMPT = """ You are an expert at video understanding and description. Your task is to capture, in as much detail as possible, the events from the video, which are related to the user's query. Be sure to capture as much description as possible about the environment, people, objects, and actions performed in the video. For example, describe the attire of the people, the make and model of the vehicles, the color of the objects, etc. Those images are samples from the video with fps {fps} frames per second. User's query: {user_prompt}. Video start timestamp: {start_timestamp}. You must begin each caption with a timestamp in pts format, and add the start_timestamp to the timestamp from each caption. The timestamp should be rounded to 2 decimal places. for example: start_timestamp: 10.0 [10.45] This is a caption. [11.24] This is another caption. should be [20.45] This is a caption. [21.24] This is another caption. """ class VideoCaptionConfig(FunctionBaseConfig, name="video_caption"): """Configuration for the Video Caption tool.""" llm_name: LLMRef = Field( ..., description="The name of the LLM to use for the image caption tool.", ) prompt: str = Field( VLM_PROMPT, description="The prompt that is used to query the VLM to understand the video", ) max_retries: int = Field( 3, description="The maximum number of retries to attempt when the VLM returns an error message.", ) max_frames_per_request: int = Field( 10, description="The maximum number of frames to request from the VLM at once. gpt4o: 10", ) use_vss: bool = Field( True, description="Whether to use VLM for video caption. If False, it will directly use the VLM(llm_name) to caption the video.", ) vss_summarize_tool: FunctionRef = Field( "vss_summarize", description="The name of the VSS summarize tool to use for video caption. If use_vss is True, it will use the VSS backend to caption the video.", ) vss_file_upload_tool: FunctionRef = Field( "vss_upload", description="The name of the VSS file upload tool to use for uploading the video file to VSS backend.", ) vss_backend_url: str = Field( "http://localhost:31000", description="The URL of the VSS backend.", ) vst_download_tool: FunctionRef = Field( default="vst_download", description="The VST tool to use for downloading video clips from VST backend" ) class VideoCaptionInput(BaseModel): """Input for the Video Caption tool""" filename: str = Field( ..., description="The filename of the video to caption (e.g., 'camera1.mp4').", ) start_timestamp: float = Field( ..., description="The start timestamp in pts of the video to understand", ) end_timestamp: float = Field( ..., description="The end timestamp in pts of the video to understand", ) user_prompt: str = Field( ..., description="The prompt that is used to query the VLM to understand the video, mention all search entities in the prompt that is related to the user's query.", ) fps: float = Field( 1.0, description="The fps to sample the video. Usually VLM works the best with fps around 1 fps", ) video_duration: float = Field( ..., description="The duration of the video in seconds", ) model_config = { "extra": "forbid", } @model_validator(mode="before") @classmethod def validate_end_timestamp(cls, info: dict) -> dict: if info["video_duration"] <= 0: raise ValueError(f"Video duration must be positive, got {info['video_duration']}") if info["end_timestamp"] is None or info["end_timestamp"] > info["video_duration"]: # Subtract small epsilon to avoid MoviePy precision issues when end_timestamp equals video_duration info["end_timestamp"] = info["video_duration"] - 0.01 return info # possible error messages from VLM, denied to help error_messages = [ "I'm sorry, I can't help with that", "I'm unable to", ] async def call_vlm_partition( llm: Any, base64_frames: list[str], template_prompt: str, user_prompt: str, start_timestamp: float, fps: float, max_retries: int, ) -> tuple[float, str]: text_prompt = template_prompt.format( fps=fps, user_prompt=user_prompt, start_timestamp=start_timestamp, ) messages = [ HumanMessage( content=[ {"type": "text", "text": text_prompt}, *[ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{frame}"}} for frame in base64_frames ], ] ) ] caption_str: str = "" for retry_idx in range(max_retries): captions = await llm.ainvoke(messages) caption_str = str(captions.content) if any(caption_str.startswith(error_msg) for error_msg in error_messages) and len(caption_str.strip()) < 80: logger.warning("VLM is unable to help %s, retry %d out of %d", caption_str, retry_idx, max_retries) new_text_prompt = await llm.ainvoke( [ SystemMessage( content="The following is a prompt that is used to caption a video, but the VLM denied to help and returned an error message. Please modify the prompt to make it more specific and easier for the VLM to understand. Only return the modified prompt, do not include any other text." ), HumanMessage(content=[{"type": "text", "text": "original prompt: " + text_prompt}]), HumanMessage(content=[{"type": "text", "text": "VLM error message: " + caption_str}]), ] ) text_prompt = new_text_prompt.content continue else: break return start_timestamp, caption_str @register_function(config_type=VideoCaptionConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def video_caption(config: VideoCaptionConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: # Get VST download tool if available vst_download_tool = None try: vst_download_tool = await builder.get_tool("vst_download", wrapper_type=LLMFrameworkEnum.LANGCHAIN) logger.info("VST download tool available") except Exception: logger.info("VST download tool not available") if config.use_vss: vss_summarize_tool = await builder.get_tool(config.vss_summarize_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) vss_file_upload_tool = await builder.get_tool( config.vss_file_upload_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN ) async def _video_caption_vss(video_caption_input: VideoCaptionInput) -> str: """ This tool uses the VLM(through VSS backend) to understand a video clip from start_timestamp to end_timestamp. video clip is sampled at fps frames per second. Input: video_caption_input: VideoCaptionInput Returns: str: The caption for the video. """ # Resolve filename to actual file path and determine cleanup needs resolved_file_path, needs_cleanup = await resolve_video_file( video_caption_input.filename, video_caption_input.start_timestamp, video_caption_input.end_timestamp, vst_download_tool, ) logger.info(f"Resolved file path: {resolved_file_path}") temp_dir_to_cleanup = None try: # Handle different storage types # For VST file upload downloaded clip to VSS vss_upload_output = await vss_file_upload_tool.ainvoke( input={ "file_path": resolved_file_path, "start_timestamp": video_caption_input.start_timestamp, "end_timestamp": video_caption_input.end_timestamp, }, ) file_id = vss_upload_output.file_id logger.info(f"Uploaded VST clip to VSS: {file_id}") # Mark temp directory for cleanup if needs_cleanup: temp_dir_to_cleanup = os.path.dirname(resolved_file_path) # summarize the video clip vss_summarize_output = await vss_summarize_tool.ainvoke( input={ "id": uuid.UUID(file_id), # Convert string to UUID "prompt": video_caption_input.user_prompt, "video_duration": video_caption_input.end_timestamp - video_caption_input.start_timestamp, "caption_summarization_prompt": "Copy all captions together with timestamps, no other text.", "summary_aggregation_prompt": f"Copy all captions to the output. Add start timestamp to the timestamp from each caption. start_timestamp is {video_caption_input.start_timestamp}", }, ) # delete from VSS if we uploaded it if not (resolved_file_path.startswith("vss_") or resolved_file_path.startswith("file_")): try: async with httpx.AsyncClient() as client: await client.delete(f"{config.vss_backend_url}/files/{file_id}") logger.info(f"Cleaned up VSS upload: {file_id}") except Exception as e: logger.warning(f"Failed to clean up VSS file: {e}") ret_str = ( "Video captions for " + video_caption_input.filename + " from " + str(video_caption_input.start_timestamp) + " to " + str(video_caption_input.end_timestamp) + ":\\n\\n" + str(vss_summarize_output.summary) ) return str(ret_str) finally: # Cleanup temporary VST download directory if needed if temp_dir_to_cleanup and os.path.exists(temp_dir_to_cleanup): logger.info(f"Cleaning up temporary directory: {temp_dir_to_cleanup}") shutil.rmtree(temp_dir_to_cleanup, ignore_errors=True) yield FunctionInfo.create( single_fn=_video_caption_vss, description=_video_caption_vss.__doc__, input_schema=VideoCaptionInput, single_output_schema=str, ) else: logger.info("Using VLM for video caption") from vss_agents.utils.frame_select import frame_select llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) loop = asyncio.get_event_loop() async def _video_caption(video_caption_input: VideoCaptionInput) -> str: """ This tool uses the VLM to understand a video clip from start_timestamp to end_timestamp. video clip is sampled at fps frames per second. IMPORTANT: - A good video clip should be 5 - 300 seconds long. - This tool is slow and expensive, only use it when necessary. - In the prompt, don't add timestamp, instead, use the start_timestamp and end_timestamp to indicate the time range of the video clip. - In the prompt, don't ask to **identify** an individual or any PII type of query, instead ask to create general descriptions about the people(attire, gender, location, etc), objects, and actions. Input: video_caption_input: VideoCaptionInput Returns: str: The caption for the video. """ # Resolve filename to actual file path and determine cleanup needs resolved_file_path, needs_cleanup = await resolve_video_file( video_caption_input.filename, video_caption_input.start_timestamp, video_caption_input.end_timestamp, vst_download_tool, ) temp_dir_to_cleanup = None try: # Mark temp directory for cleanup if needed if needs_cleanup: temp_dir_to_cleanup = os.path.dirname(resolved_file_path) step_size = 1 / video_caption_input.fps with TimeMeasure( f"frame_select-{resolved_file_path}, {video_caption_input.start_timestamp}, { video_caption_input.end_timestamp }, {video_caption_input.fps}" ): base64_frames = await loop.run_in_executor( None, frame_select, resolved_file_path, video_caption_input.start_timestamp, video_caption_input.end_timestamp, step_size, ) tasks = [] for i in range(0, len(base64_frames), config.max_frames_per_request): start_timestamp = video_caption_input.start_timestamp + i * step_size tasks.append( call_vlm_partition( llm, base64_frames[i : i + config.max_frames_per_request], config.prompt, video_caption_input.user_prompt, start_timestamp, video_caption_input.fps, config.max_retries, ) ) results = await asyncio.gather(*tasks) results.sort(key=lambda x: x[0]) ret_str = ( "Video captions for " + video_caption_input.filename + " from " + str(video_caption_input.start_timestamp) + " to " + str(video_caption_input.end_timestamp) + ":\n\n" + "\n".join([result[1] for result in results]) ) return ret_str except Exception as e: logger.error(f"Error captioning video {video_caption_input.filename}: {e}") raise e finally: # Cleanup temporary VST download directory if needed if temp_dir_to_cleanup and os.path.exists(temp_dir_to_cleanup): logger.info(f"Cleaning up temporary directory: {temp_dir_to_cleanup}") shutil.rmtree(temp_dir_to_cleanup, ignore_errors=True) yield FunctionInfo.create( single_fn=_video_caption, description=_video_caption.__doc__, input_schema=VideoCaptionInput, single_output_schema=str, ) ================================================ FILE: agent/src/vss_agents/tools/video_detailed_caption.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from pydantic import model_validator logger = logging.getLogger(__name__) class VideoDetailedCaptionConfig(FunctionBaseConfig, name="video_detailed_caption"): """Configuration for the Video Detailed Caption tool.""" detailed_fps: float = Field( 2.0, description="The fixed fps to sample the video when detailed captioning short videos.", ) max_video_duration: float = Field( 60, description="The maximum duration of the video for captioning in seconds. If the video duration is longer than this value, a message will be returned to agent to caption a shorter video or use skimming.", ) class VideoDetailedCaptionInput(BaseModel): """Input for the Video Detailed Caption tool""" filename: str = Field( ..., description="The filename of the video to caption (e.g., 'camera1.mp4').", ) start_timestamp: float = Field( ..., description="The start timestamp in pts of the video to understand", ) end_timestamp: float = Field( ..., description="The end timestamp in pts of the video to understand", ) user_prompt: str = Field( ..., description="The prompt that is used to query the VLM to understand the video, mention all search entities in the prompt that is related to the user's query.", ) video_duration: float = Field( ..., description="The duration of the video in seconds", ) model_config = { "extra": "forbid", } @model_validator(mode="before") @classmethod def validate_end_timestamp(cls, info: dict) -> dict: if info["video_duration"] <= 0: raise ValueError(f"Video duration must be positive, got {info['video_duration']}") if info["end_timestamp"] is None or info["end_timestamp"] > info["video_duration"]: # Subtract small epsilon to avoid MoviePy precision issues when end_timestamp equals video_duration info["end_timestamp"] = info["video_duration"] - 0.01 return info @register_function(config_type=VideoDetailedCaptionConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def video_detailed_caption(config: VideoDetailedCaptionConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: async def _video_detailed_caption(video_detailed_caption_input: VideoDetailedCaptionInput) -> str: """ This tool uses the VLM to understand a shorter video clip in detail from start_timestamp to end_timestamp. video clip is sampled at a higher fps - frames per second. IMPORTANT: - This tool is slow and expensive, only use it when necessary. - In the prompt, don't add timestamp, instead, use the start_timestamp and end_timestamp to indicate the time range of the video clip. - In the prompt, don't ask to **identify** an individual or any PII type of query, instead ask to create general descriptions about the people(attire, gender, location, etc), objects, and actions. Input: video_detailed_caption_input: VideoDetailedCaptionInput Returns: str: The caption for the video. """ captioning_duration = video_detailed_caption_input.end_timestamp - video_detailed_caption_input.start_timestamp if captioning_duration > config.max_video_duration: return ( "Video duration is too long for detailed captioning, please caption a shorter video of less than " + str(config.max_video_duration) + " seconds or use video_skim_caption tool." ) # Create a VideoCaptionInput object and call video caption tool video_caption_input = { "filename": video_detailed_caption_input.filename, "start_timestamp": video_detailed_caption_input.start_timestamp, "end_timestamp": video_detailed_caption_input.end_timestamp, "user_prompt": video_detailed_caption_input.user_prompt, "fps": config.detailed_fps, "video_duration": video_detailed_caption_input.video_duration, } # Call video caption tool video_caption_tool = await builder.get_tool("video_caption", wrapper_type=LLMFrameworkEnum.LANGCHAIN) try: ret_str: str = await video_caption_tool.ainvoke(video_caption_input) except Exception as e: logger.error(f"Error calling video_caption_tool: {e}") logger.error(f"Error type: {type(e)}") raise e return str(ret_str) yield FunctionInfo.create( single_fn=_video_detailed_caption, description=_video_detailed_caption.__doc__, input_schema=VideoDetailedCaptionInput, single_output_schema=str, ) ================================================ FILE: agent/src/vss_agents/tools/video_frame_timestamp.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 from collections.abc import AsyncGenerator from datetime import datetime import logging import cv2 from langchain_core.prompts import ChatPromptTemplate from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.prompt import VIDEO_FRAME_TIMESTAMP_PROMPT logger = logging.getLogger(__name__) class VideoFrameTimestampConfig(FunctionBaseConfig, name="video_frame_timestamp"): """Configuration for the Video Frame Timestamp tool.""" llm_name: str = Field( "openai_llm", description="The name of the LLM to use.", ) prompt: str = Field( VIDEO_FRAME_TIMESTAMP_PROMPT, description="Prompt for video frame timestamp", ) class VideoFrameTimestampInput(BaseModel): """Input for the Video Frame Timestamp tool""" asset_file_path: str = Field( ..., description="The path to the asset to summarize", ) frame_offset_seconds: float = Field( ..., description="The offset in seconds from the start of the video to get the timestamp", ) @register_function(config_type=VideoFrameTimestampConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def video_frame_timestamp(config: VideoFrameTimestampConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: async def _video_frame_timestamp(video_frame_timestamp_input: VideoFrameTimestampInput) -> datetime: """ Given an offset in seconds from the start of the video, return the timestamp of the video frame. Using a VLM to extract it from the image. Returns: str: The timestamp of the video frame. """ # extract the frame from the video given the offset video_capture = cv2.VideoCapture(video_frame_timestamp_input.asset_file_path) video_capture.set(cv2.CAP_PROP_POS_MSEC, video_frame_timestamp_input.frame_offset_seconds * 1000) _, frame = video_capture.read() video_capture.release() _, buffer = cv2.imencode(".jpg", frame) base64_frame = base64.b64encode(buffer.tobytes()).decode("utf-8") llm = await builder.get_llm(config.llm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) prompt = ChatPromptTemplate( [ { "role": "system", "content": config.prompt, }, { "role": "user", "content": [ { "type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_frame}", "detail": "auto"}, }, ], }, ] ) chain = prompt | llm result = await chain.ainvoke({"base64_frame": base64_frame}) # 2024-05-30T01:41:25.000Z return datetime.strptime(result.content, "%Y-%m-%dT%H:%M:%S.%fZ") yield FunctionInfo.create( single_fn=_video_frame_timestamp, description=_video_frame_timestamp.__doc__, input_schema=VideoFrameTimestampInput, single_output_schema=datetime, ) ================================================ FILE: agent/src/vss_agents/tools/video_report_gen.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Report Generation Tool for uploaded videos. Generates reports for uploaded videos without Video Analytics MCP infrastructure. Handles VLM prompt sanitization, video analysis, and report formatting. """ import asyncio from collections import OrderedDict from collections.abc import AsyncGenerator from datetime import datetime from datetime import timedelta import json import logging import os import re import tempfile from typing import Any from typing import NamedTuple import urllib.parse try: import markdown from xhtml2pdf import pisa PDF_CONVERSION_AVAILABLE = True except ImportError: PDF_CONVERSION_AVAILABLE = False from nat.builder.builder import Builder from nat.builder.context import Context from nat.builder.context import ContextState from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import FunctionRef from nat.data_models.component_ref import ObjectStoreRef from nat.data_models.function import FunctionBaseConfig from nat.data_models.interactive import HumanPromptText from nat.data_models.interactive import InteractionResponse from nat.object_store.models import ObjectStoreItem from pydantic import BaseModel from pydantic import Field from vss_agents.tools.lvs_video_understanding import LVSStatus from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import get_stream_id from vss_agents.tools.vst.video_clip import get_video_url from vss_agents.utils.reasoning_parsing import parse_reasoning_content from vss_agents.utils.time_convert import datetime_to_iso8601 from vss_agents.utils.time_convert import iso8601_to_datetime logger = logging.getLogger(__name__) CHUNK_TIMESTAMP_PROMPT = """ All events from the video should fall within the time range: START_TIME: {start_time}s END_TIME: {end_time}s """ def _get_object_store_url(object_store: Any, filename: str, config: "VideoReportGenConfig") -> str: """ Get HTTP URL for a file from any object store type. Supports: - S3/MinIO object store (construct URL from endpoint) - in_memory and other stores (use NAT file server /static/ endpoint) Args: object_store: The object store instance filename: The file key/name config: The Video report gen config Returns: str: HTTP URL to access the file """ # S3/MinIO object store - construct URL from attributes if hasattr(object_store, "endpoint_url") and hasattr(object_store, "bucket_name"): endpoint = object_store.endpoint_url bucket = object_store.bucket_name endpoint = endpoint.rstrip("/") return f"{endpoint}/{bucket}/{filename}" # For in_memory and other stores - use NAT's /static/ endpoint from config base_url = config.base_url.rstrip("/") return f"{base_url}/{filename}" def _divide_video_into_chunks( duration_seconds: float, chunk_duration_seconds: int = 60, ) -> list[tuple[float, float]]: """ Divide a video timeframe into chunks. Args: duration_seconds: Duration of the video in seconds chunk_duration_seconds: Duration of each chunk in seconds Returns: List of (chunk_start, chunk_end) tuples in seconds(offset from the start of the video) """ if chunk_duration_seconds <= 0: raise ValueError( f"Video Analysis Report: chunk_duration_seconds must be positive, got {chunk_duration_seconds}" ) chunks: list[tuple[float, float]] = [] current_start: float = 0.0 while current_start < duration_seconds: current_end = min(current_start + chunk_duration_seconds, duration_seconds) chunks.append( ( current_start, current_end, ) ) current_start = current_end return chunks def _remove_som_markers(prompt: str) -> str: """ Remove Set-of-Mark (SOM) markers from VLM prompts. SOM markers are used in Video Analytics MCP mode to reference specific tracked objects, but are not applicable for Video(uploaded) Report mode where videos lack object tracking data. Removes: - {object_ids} placeholder - Sentences mentioning "object ids" or "object IDs" - Any remaining object ID references Args: prompt: The original VLM prompt Returns: Cleaned prompt without SOM markers """ # Remove {object_ids} placeholder cleaned = re.sub(r"\{object_ids\}", "", prompt) # Remove sentences mentioning object IDs cleaned = re.sub( r"Focus only on.*?object ids[^.]*\.\s*", "", cleaned, flags=re.IGNORECASE, ) cleaned = re.sub( r"Include only.*?object ids[^.]*\.\s*", "", cleaned, flags=re.IGNORECASE, ) # Clean up any extra whitespace cleaned = re.sub(r"\s+", " ", cleaned).strip() return cleaned def _replace_public_urls_with_private( markdown_content: str, vst_internal_url: str | None, vst_external_url: str | None ) -> str: """ Replace external (public) URLs in image tags with internal (private) IP URLs for PDF generation. Args: markdown_content: Markdown content with image URLs vst_internal_url: Internal VST URL (e.g., 'http://10.0.0.1:30888') - private IP for PDF vst_external_url: External VST URL (e.g., 'http://public.example.com:30888') - public URL to replace Returns: Markdown content with image URLs updated to use private IP """ if not vst_internal_url or not vst_external_url: logger.debug( f"URL replacement skipped - vst_internal_url: {vst_internal_url is not None}, " f"vst_external_url: {vst_external_url is not None}" ) return markdown_content # Extract base URLs (scheme + host + port) internal_match = re.match(r"(https?://[^/]+)", vst_internal_url) external_match = re.match(r"(https?://[^/]+)", vst_external_url) if not internal_match or not external_match: logger.warning(f"Could not parse URLs - internal: {vst_internal_url}, external: {vst_external_url}") return markdown_content internal_base = internal_match.group(1) # e.g., 'http://10.0.0.1:30888' external_base = external_match.group(1) # e.g., 'http://203.0.113.1:30888' logger.info(f"Replacing external URL '{external_base}' with internal URL '{internal_base}' in image URLs for PDF") # Replace URLs in image tags only (both def replace_img_src(match: re.Match[str]) -> str: full_match = match.group(0) url = match.group(1) # Replace external base with internal base if found if external_base in url: new_url = url.replace(external_base, internal_base) return full_match.replace(url, new_url) return full_match # Replace in str: full_match = match.group(0) url = match.group(2) # Replace external base with internal base if found if external_base in url: new_url = url.replace(external_base, internal_base) return full_match.replace(url, new_url) return full_match # Replace in ![alt](url) format result = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", replace_md_img, result) return result def _convert_markdown_to_pdf(markdown_file_path: str, output_pdf_path: str) -> bool: """Convert markdown file to PDF using Python packages.""" if not PDF_CONVERSION_AVAILABLE: logger.warning( "Video Analysis Report: PDF conversion not available. Install 'markdown' and 'xhtml2pdf' packages." ) return False try: # Read markdown file with open(markdown_file_path, encoding="utf-8") as f: markdown_content = f.read() # Convert markdown to HTML html_content = markdown.markdown(markdown_content, extensions=["tables", "fenced_code"]) # Add professional CSS styling with NVIDIA branding styled_html = f""" {html_content} """ # Convert HTML to PDF with open(output_pdf_path, "wb") as pdf_file: pisa_status = pisa.CreatePDF(styled_html, dest=pdf_file) if pisa_status.err: logger.error(f"Video Analysis Report: PDF conversion had errors: {pisa_status.err}") return False logger.info(f"Successfully converted markdown to PDF: {output_pdf_path}") return True except Exception as e: logger.error(f"Video Analysis Report: Error converting markdown to PDF: {e}") return False class VideoReportGenConfig(FunctionBaseConfig, name="video_report_gen"): """Configuration for Video(uploaded) Report generation tool.""" object_store: ObjectStoreRef = Field( ..., description="Reference to the object store for serving files via HTTP", ) base_url: str = Field( default="http://localhost:8000/static", description="Base URL for file server (used for in_memory and other non-S3 object stores)", ) video_understanding_tool: FunctionRef = Field( ..., description="Name of the video understanding tool to use for short videos", ) lvs_video_understanding_tool: str | None = Field( default=None, description="Name of the LVS video understanding tool to use for long videos. If None, LVS is disabled.", ) lvs_video_length: int = Field( default=60, description="Minimum length of a video in seconds to use LVS for analysis. If the video duration is longer than this value, LVS will be used for analysis.", ) vlm_prompt: str = Field( default="Describe in detail what is happening in this video, including all visible people, objects, actions, and environmental conditions.", description="Prompt to query the VLM for video understanding. SOM markers will be automatically removed.", ) normalize_timestamps: bool = Field( default=True, description="Normalize timestamps in the VLM response content to absolute video time, set to true for CR1", ) chunk_duration_seconds: int = Field( default=60, description="Duration of each video chunk in seconds for parallel processing.", ) max_duration_for_chunking: int = Field( default=300, description="Maximum duration of a video in seconds for chunking.", ) video_url_tool: FunctionRef | None = Field( default=None, description="Tool to get video playback URL by sensor ID (optional)", ) picture_url_tool: FunctionRef | None = Field( default=None, description="Tool to get snapshot picture URL by sensor ID and timestamp (optional)", ) vst_internal_url: str | None = Field( default=None, description="Internal VST URL for API calls (e.g., 'http://${INTERNAL_IP}:30888'). If not provided, uses VST_INTERNAL_URL env var.", ) vst_external_url: str | None = Field( default=None, description="External VST URL for client-facing URLs (e.g., 'http://${EXTERNAL_IP}:30888'). If not provided, uses VST_EXTERNAL_URL env var.", ) # HITL Configuration (optional - if not set, HITL is disabled) hitl_enabled: bool = Field( default=False, description="Enable HITL for VLM prompt confirmation before report generation.", ) hitl_vlm_prompt_template: str | None = Field( default=None, description="HITL template for collecting/confirming VLM prompt from user. If None and hitl_enabled=True, uses a default template.", ) hitl_prompt_llm: str | None = Field( default=None, description="LLM to use for AI-assisted prompt generation (/generate and /refine commands). If None, AI features disabled.", ) hitl_generate_system_prompt: str = Field( default="""You are a prompt engineer specializing in video analysis. Your task is to create a clear, detailed prompt for a Vision Language Model (VLM) that will analyze video footage. Requirements for the generated prompt: - Be specific about what to look for in the video - Include instructions to describe events with timestamps in chronological[Xs-Ys] format - Focus on the user's described scenario/goals - Keep the prompt concise but comprehensive Output ONLY the VLM prompt, no explanations or preamble.""", description="System prompt for the /generate command. User's description will be appended.", ) hitl_refine_system_prompt: str = Field( default="""You are a prompt engineer specializing in video analysis. Your task is to modify an existing VLM prompt based on the user's instructions. Requirements: - Preserve the timestamp format [Xs-Ys] requirement - Incorporate the user's requested changes - Keep the prompt structure clear and actionable - Output ONLY the modified prompt, no explanations Current prompt to modify: {current_prompt} User's modification request:""", description="System prompt for the /refine command. Contains {current_prompt} placeholder.", ) class VideoReportGenInput(BaseModel): """Input for Video(uploaded) Report generation.""" sensor_id: str = Field( ..., description="VST sensor ID (filename of uploaded video, e.g., 'warehouse_01.mp4')", ) user_query: str = Field( ..., description="The user's question or analysis request for this video", ) vlm_reasoning: bool | None = Field( default=None, description="Enable VLM reasoning mode for video analysis", ) model_config = { "extra": "forbid", } class VideoReportGenOutput(BaseModel): """Output from Video(uploaded) Report generation.""" http_url: str | None = Field(default=None, description="HTTP URL to access the markdown report file") pdf_url: str | None = Field(default=None, description="HTTP URL to access the PDF report file (if generated)") object_store_key: str | None = Field(default=None, description="Key/filename in the object store") summary: str | None = Field(default=None, description="Brief summary of the report (or cancellation message)") file_size: int = Field(default=0, description="Size of the markdown report file in bytes") pdf_file_size: int = Field(default=0, description="Size of the PDF report file in bytes") content: str | None = Field(default=None, description="The actual markdown content of the generated report") video_url: str | None = Field(default=None, description="The URL of the video playback") hitl_prompts: dict | None = Field(default=None, description="HITL prompts used for the report") async def _save_markdown_to_object_store( markdown_content: str, filename: str, object_store: Any, config: VideoReportGenConfig, ) -> tuple[str, int]: """Save markdown content to object store.""" content_bytes = markdown_content.encode("utf-8") file_size = len(content_bytes) timestamp = datetime.now() metadata = { "timestamp": timestamp.strftime("%Y%m%d_%H%M%S"), "generated_at": timestamp.isoformat(), "file_size": str(file_size), "content_type": "text/markdown", "report_type": "video report", } object_store_item = ObjectStoreItem(data=content_bytes, content_type="text/markdown", metadata=metadata) await object_store.upsert_object(filename, object_store_item) logger.info(f"Markdown report saved to object store: {filename}") # Get HTTP URL http_url = _get_object_store_url(object_store, filename, config) return http_url, file_size async def _save_pdf_to_object_store( markdown_content: str, filename: str, pdf_filename: str, object_store: Any, config: VideoReportGenConfig, ) -> tuple[str | None, int]: """Generate PDF from markdown and save to object store. Returns URL and size.""" pdf_file_size = 0 pdf_url = None with tempfile.TemporaryDirectory() as temp_dir: temp_md_path = os.path.join(temp_dir, filename) temp_pdf_path = os.path.join(temp_dir, pdf_filename) # Replace public URLs with private IPs for image URLs before PDF generation pdf_markdown_content = _replace_public_urls_with_private( markdown_content, config.vst_internal_url, config.vst_external_url ) # Log the complete markdown content before saving to temp file logger.debug("=" * 80) logger.debug("MARKDOWN CONTENT BEFORE PDF GENERATION (with internal IPs)") logger.debug("=" * 80) logger.debug(pdf_markdown_content) logger.debug("=" * 80) logger.debug("END OF MARKDOWN CONTENT") logger.debug("=" * 80) # Write markdown to temp file and convert to PDF with open(temp_md_path, "w", encoding="utf-8") as f: f.write(pdf_markdown_content) if _convert_markdown_to_pdf(temp_md_path, temp_pdf_path): with open(temp_pdf_path, "rb") as f: pdf_bytes = f.read() pdf_file_size = len(pdf_bytes) timestamp = datetime.now() pdf_object_store_item = ObjectStoreItem( data=pdf_bytes, content_type="application/pdf", metadata={ "timestamp": timestamp.strftime("%Y%m%d_%H%M%S"), "generated_at": timestamp.isoformat(), "file_size": str(pdf_file_size), "content_type": "application/pdf", "report_type": "video report", }, ) await object_store.upsert_object(pdf_filename, pdf_object_store_item) # Get HTTP URL pdf_url = _get_object_store_url(object_store, pdf_filename, config) logger.info(f"PDF report saved to object store: {pdf_filename}") else: logger.warning("Video Analysis Report: Failed to generate PDF report") return pdf_url, pdf_file_size class TimestampMatch(NamedTuple): """Parsed timestamp from VLM response content.""" position: int # Character position in content string seconds: float # Timestamp in seconds def _parse_timestamps(content: str) -> list[TimestampMatch]: """ Parse timestamps from content in [Xs-Ys] format. Matches: [5.2s-8.0s] or [15s - 20s] etc. Uses midpoint of the span for snapshot. Returns list of TimestampMatch with position and midpoint seconds. """ matches: list[TimestampMatch] = [] # [Xs-Ys] format pattern = re.compile( r"(?:\*\*\s*)?" # optional leading ** and spaces r"\[\s*" # literal [ r"(\d+(?:\.\d+)?)(?:s)?" # group 1: start time r"\s*-\s*" r"(\d+(?:\.\d+)?)(?:s)?" # group 2: end time r"\s*\]" # literal ] r"(?:\s*\*\*)?" # optional trailing ** and spaces ) for match in re.finditer(pattern, content): start_seconds = float(match.group(1)) end_seconds = float(match.group(2)) midpoint = (start_seconds + end_seconds) / 2 matches.append(TimestampMatch(position=match.start(), seconds=midpoint)) return matches def _normalize_chunk_timestamps(content: str, chunk_start: float, chunk_end: float) -> str: """ Normalize timestamps in VLM response content by adding chunk offset. VLM returns timestamps relative to the chunk (starting from 0s). This function: 1. Finds the max end timestamp in the content 2. Computes ratio = max_end / chunk_duration 3. If ratio > 1, scales all timestamps down by the ratio 4. Adds chunk_start offset to convert to absolute video time Args: content: VLM response content with relative timestamps in [Xs-Ys] format chunk_start: Start time of the chunk in seconds (offset to add) chunk_end: End time of the chunk in seconds (for ratio calculation) Returns: Content with timestamps normalized to absolute video time """ # [Xs-Ys] format pattern = re.compile( r"(?:\*\*\s*)?" # optional leading ** and spaces r"\[\s*" # literal [ r"(\d+(?:\.\d+)?)(?:s)?" # group 1: start time r"\s*-\s*" r"(\d+(?:\.\d+)?)(?:s)?" # group 2: end time r"\s*\]" # literal ] r"(?:\s*\*\*)?" # optional trailing ** and spaces ) # First pass: find all timestamps and the max end value matches_data: list[tuple[re.Match, float, float]] = [] max_end_sec = 0.0 for match in re.finditer(pattern, content): start_sec = float(match.group(1)) end_sec = float(match.group(2)) matches_data.append((match, start_sec, end_sec)) max_end_sec = max(max_end_sec, end_sec) chunk_duration = chunk_end - chunk_start if not matches_data or chunk_duration <= 0: return content # Compute normalization ratio ratio = max_end_sec / chunk_duration should_normalize = ratio > 1.0 if should_normalize: logger.info( f"Normalizing chunk timestamps: max_end_sec={max_end_sec:.1f}s, " f"chunk_duration={chunk_duration:.1f}s, ratio={ratio:.2f}" ) # Second pass: replace timestamps with normalized values result = content for match, start_sec, end_sec in matches_data: # Scale timestamps if ratio differs significantly from 1.0 if should_normalize: start_sec /= ratio end_sec /= ratio # Add chunk offset to convert to absolute time abs_start = chunk_start + start_sec abs_end = chunk_start + end_sec replacement = f"[{abs_start:.1f}s-{abs_end:.1f}s]" result = result.replace(match.group(0), replacement, 1) return result def _filter_short_duration_from_markdown(content: str, min_duration_seconds: float = 2.0) -> str: """ Filter out sentences/lines containing timestamp ranges with duration less than the specified threshold. Parses markdown content for [Xs-Ys] timestamp patterns, calculates duration, and removes lines describing events shorter than min_duration_seconds. Args: content: Markdown content with timestamps in [Xs-Ys] format min_duration_seconds: Minimum event duration in seconds (default: 2.0) Returns: Filtered markdown content with short duration events removed """ if not content: return content # Pattern to match [Xs-Ys] timestamps timestamp_pattern = re.compile(r"\[\s*(\d+(?:\.\d+)?)(?:s)?\s*-\s*(\d+(?:\.\d+)?)(?:s)?\s*\]") # Process line by line lines = content.split("\n") filtered_lines = [] for line in lines: # Find all timestamps in the line matches = list(timestamp_pattern.finditer(line)) if not matches: # No timestamps, keep the line filtered_lines.append(line) continue # Check if any timestamp in this line has sufficient duration has_valid_duration = False for match in matches: start_time = float(match.group(1)) end_time = float(match.group(2)) duration = end_time - start_time if duration >= min_duration_seconds: has_valid_duration = True break if has_valid_duration: filtered_lines.append(line) else: # Log the filtered line for debugging duration = float(matches[0].group(2)) - float(matches[0].group(1)) line_preview = line.strip()[:100] logger.info( f"Filtered out short duration line (duration={duration:.1f}s < {min_duration_seconds:.1f}s): " f"{line_preview}" ) return "\n".join(filtered_lines) def _mmss_to_iso(time_str: str, ref_timestamp: str) -> str: """ Convert MM:SS or Xs to ISO 8601 timestamp by adding offset to reference timestamp. Args: time_str: Time string in "MM:SS" format (e.g., "01:30") or "Xs" format (e.g., "5.2s") ref_timestamp: Reference timestamp in ISO 8601 format to add the offset to Returns: ISO timestamp string with offset added to ref_timestamp """ if time_str.endswith("s"): # Seconds format from LVS (e.g., "5.2s") total_seconds = int(float(time_str[:-1])) else: # MM:SS format from regular VLM parts = time_str.split(":") minutes = int(parts[0]) seconds = int(parts[1]) total_seconds = minutes * 60 + seconds # Parse reference timestamp and add offset ref_dt = iso8601_to_datetime(ref_timestamp) result_dt = ref_dt + timedelta(seconds=total_seconds) return datetime_to_iso8601(result_dt) async def _inject_video_clips( content: str, sensor_id: str, vst_internal_url: str | None, vst_external_url: str | None, ) -> str: """ Parse timestamps from content and inject video clip links. For each timestamp range [Xs-Ys] found: 1. Parse start and end times 2. Generate video clip URL using VST 3. Inject [Watch Clip] link right after the timestamp Args: content: Markdown content with timestamps in [Xs-Ys] format sensor_id: Video sensor ID vst_internal_url: VST internal URL vst_external_url: VST external URL Returns: Content with [Watch Clip] links injected after timestamps Note: Video clip durations may be slightly longer than the timestamp range due to VST aligning clips to video keyframes (I-frames) for proper playback. For example, [120s-130s] (10s) may result in a 12-13 second clip. """ if not (vst_internal_url and vst_external_url): logger.debug("Video Analysis Report: VST URLs not configured, skipping video clip injection") return content # Pattern to match [Xs-Ys] timestamps pattern = re.compile( r"(\[\s*" # group 1: opening bracket and spaces r"(\d+(?:\.\d+)?)(?:s)?" # group 2: start time r"\s*-\s*" r"(\d+(?:\.\d+)?)(?:s)?" # group 3: end time r"\s*\])" # closing bracket ) matches = list(pattern.finditer(content)) if not matches: logger.debug("Video Analysis Report: No timestamps found in content for video clip injection") return content try: stream_id = await get_stream_id(sensor_id, vst_internal_url) except Exception as e: logger.warning(f"Failed to get stream_id for video clips: {e}") return content # Process matches in reverse order to preserve positions result_content = content for match in reversed(matches): start_time = float(match.group(2)) end_time = float(match.group(3)) try: logger.info(f"Generating video clip URL for [{start_time}s-{end_time}s]") clip_url = await get_video_url( stream_id=stream_id, start_time=start_time, end_time=end_time, vst_internal_url=vst_internal_url, ) # Replace internal URL with external URL for client access clip_url = f"{vst_external_url}{urllib.parse.urlparse(clip_url).path}" video_clip_link = f" [[Watch Clip]({clip_url})]" # Inject right after the timestamp insert_pos = match.end() result_content = result_content[:insert_pos] + video_clip_link + result_content[insert_pos:] except Exception as e: logger.warning(f"Failed to generate video clip URL for [{start_time}s-{end_time}s]: {e}") continue return result_content async def _inject_snapshots( content: str, sensor_id: str, picture_url_tool: Any, ) -> str: """ Parse timestamps from content, fetch snapshots, and inject images after the sentence. For each timestamp span found: 1. Extract the midpoint of the timestamp span 2. Call picture_url_tool to get snapshot at that time 3. Find the next period after the timestamp 4. Insert image markdown after that period Args: content: VLM markdown content with normalized timestamps sensor_id: Video sensor ID picture_url_tool: Tool to fetch snapshot URLs Returns: Content with snapshot images injected """ if not picture_url_tool: logger.warning("Video Analysis Report: No picture_url_tool configured, skipping snapshot injection") return content timestamps = _parse_timestamps(content) if not timestamps: logger.warning("Video Analysis Report: No timestamps found in VLM response for snapshot injection") return content image_urls = await asyncio.gather( *[ picture_url_tool.ainvoke( input={ "sensor_id": sensor_id, "start_time": ts.seconds, } ) for ts in timestamps ] ) result_content = content for ts, image_url in reversed(list(zip(timestamps, image_urls, strict=False))): # Format seconds to readable string for alt text mins = int(ts.seconds) // 60 secs = int(ts.seconds) % 60 time_str = f"{mins:02d}:{secs:02d}" # Use HTML img tag for size control with minimal spacing # Add style to control margins for PDF rendering image_md = ( f'\n\nSnapshot at {time_str}\n' ) result_content = result_content[: ts.position] + image_md + result_content[ts.position :] return result_content def _clean_vlm_response(vlm_response: str) -> str: """ Clean and validate the VLM markdown response. Removes code block wrappers, thinking/answer tags, and extracts the actual markdown report content. """ cleaned = vlm_response.strip() # Remove ... blocks (with closing tag) cleaned = re.sub(r".*?", "", cleaned, flags=re.DOTALL | re.IGNORECASE) # If response starts with but no closing tag, find the first markdown heading if cleaned.strip().lower().startswith(""): # Find the first markdown heading (# at start of line) heading_match = re.search(r"^#+ ", cleaned, re.MULTILINE) if heading_match: cleaned = cleaned[heading_match.start() :].strip() else: # Just remove the tag cleaned = re.sub(r"^\s*", "", cleaned, flags=re.IGNORECASE) # If there's a tag, delete everything before it (including the tag itself) # This handles cases where LLM outputs thinking without opening tag think_end_match = re.search(r"", cleaned, flags=re.IGNORECASE) if think_end_match: # Keep everything after the tag cleaned = cleaned[think_end_match.end() :].strip() # Check for tags and extract content within them answer_match = re.search(r"(.*?)", cleaned, flags=re.DOTALL | re.IGNORECASE) if answer_match: cleaned = answer_match.group(1).strip() else: # Remove and tags if present but not properly paired cleaned = re.sub(r"", "", cleaned, flags=re.IGNORECASE) # Clean up whitespace cleaned = cleaned.strip() # Remove markdown code block wrappers (do this after think tag removal) # Handle various code block types: ```markdown, ```plaintext, ```text, ``` code_block_prefixes = ["```markdown", "```plaintext", "```text", "```"] for prefix in code_block_prefixes: if cleaned.startswith(prefix): cleaned = cleaned[len(prefix) :].strip() break if cleaned.endswith("```"): cleaned = cleaned[:-3].strip() return cleaned def _filter_short_events(events: list[dict | Any], min_duration_seconds: float = 2.0) -> list[dict | Any]: """ Filter out events with duration less than the specified threshold. Events shorter than min_duration_seconds are removed to reduce noise in reports. Events with invalid or missing timestamps are kept as-is. Args: events: List of event dictionaries with start_time and end_time fields min_duration_seconds: Minimum event duration in seconds (default: 2.0) Returns: List of events with duration >= min_duration_seconds """ filtered_events = [] for event in events: if isinstance(event, dict): start_time = event.get("start_time", "N/A") end_time = event.get("end_time", "N/A") # Skip events with invalid times or duration less than threshold if start_time != "N/A" and end_time != "N/A": try: duration = float(end_time) - float(start_time) if duration >= min_duration_seconds: filtered_events.append(event) else: logger.info( f"Filtered out short event (duration={duration:.1f}s < {min_duration_seconds:.1f}s): " f"{event.get('description', '')[:50]}" ) except (ValueError, TypeError): # If we can't parse times, keep the event filtered_events.append(event) else: # If times are missing, keep the event filtered_events.append(event) else: # Non-dict events are kept as-is filtered_events.append(event) return filtered_events def _format_lvs_response(lvs_response: str) -> str: """ Format the LVS video understanding tool response into a readable markdown template. The lvs_video_understanding tool returns JSON like: { "video_summary": "...", "events": [...], "hitl_prompts": { "scenario": "...", "events": [...], "objects_of_interest": [...] }, "lvs_backend_response": {...} } Note: The LVS backend service itself only returns video_summary and events. The hitl_prompts are added by the lvs_video_understanding tool wrapper. Video clip links are injected later by _inject_video_clips in the main workflow. Args: lvs_response: JSON string from LVS tool """ try: lvs_data = json.loads(lvs_response) # Extract fields video_summary = lvs_data.get("video_summary", "") events = lvs_data.get("events", []) # Clean thinking tags from video_summary video_summary = _clean_vlm_response(video_summary) # Build formatted output formatted_lines = [] if video_summary: formatted_lines.extend( [ "**Video Summary:**", "", video_summary, "", ] ) if events: # Filter out events that are less than 2 seconds in duration filtered_events = _filter_short_events(events, min_duration_seconds=2.0) if filtered_events: event_count = len(filtered_events) formatted_lines.extend( [ "**Events:**", "", f"{event_count} event(s) were detected in the video. See details below.", "", ] ) for event in filtered_events: if isinstance(event, dict): start_time = event.get("start_time", "N/A") end_time = event.get("end_time", "N/A") description = event.get("description", "") # Clean thinking tags from description description = _clean_vlm_response(description) formatted_lines.append(f"- **[{start_time}s - {end_time}s]**: {description}") else: formatted_lines.append(f"- {event}") else: formatted_lines.append("*No events detected.*") else: formatted_lines.append("*No events detected.*") return "\n".join(formatted_lines) except (json.JSONDecodeError, Exception) as e: logger.warning(f"Video Analysis Report: Failed to parse LVS response as JSON: {e}, returning raw response") return lvs_response def _create_report_header( sensor_id: str, user_query: str, hitl_prompts: dict | None = None, ) -> str: """ Create the standard report header with metadata. Args: sensor_id: The video sensor ID user_query: The user's analysis request hitl_prompts: Optional HITL prompts dict (scenario, events, objects_of_interest) from LVS """ now = datetime.now() report_date = now.strftime("%Y-%m-%d") report_time = now.strftime("%H:%M:%S") report_timestamp = now.strftime("%Y%m%d_%H%M%S") vss_agent_version = os.getenv("VSS_AGENT_VERSION", "dev") report_lines = [ "# Video Analysis Report", "", "## Basic Information", "", "| Field | Value |", "|-------|-------|", f"| **Report Identifier** | vss_report_{report_timestamp} |", f"| **Date of Analysis** | {report_date} |", f"| **Time of Analysis** | {report_time} |", f"| **Reporting AI Agent** | vss_agent {vss_agent_version} |", f"| **Video Source** | {sensor_id} |", f"| **Analysis Request** | {user_query} |", ] if hitl_prompts: # Add HITL prompts to Basic Information table scenario = hitl_prompts.get("scenario", "") if scenario: report_lines.append(f"| **Prompt - Scenario** | {scenario} |") events_list = hitl_prompts.get("events", []) if events_list: report_lines.append(f"| **Prompt - Events of Interest** | {', '.join(events_list)} |") objects_list = hitl_prompts.get("objects_of_interest", []) if objects_list: report_lines.append(f"| **Prompt - Objects of Interest** | {', '.join(objects_list)} |") report_lines.append("") # Close table with empty line report_lines.extend( [ "## Analysis Results", "", "", ] ) return "\n".join(report_lines) @register_function(config_type=VideoReportGenConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def video_report_gen(config: VideoReportGenConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Video(uploaded) Report Generation Tool. Generates comprehensive video analysis reports for uploaded videos without Video Analytics MCP. Handles VLM prompt sanitization, video analysis, and optional template-based formatting. """ # Load tools object_store = await builder.get_object_store_client(config.object_store) video_understanding_tool = await builder.get_tool( config.video_understanding_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN ) # Load LVS tool if configured (optional) lvs_video_understanding_tool = None if config.lvs_video_understanding_tool is not None: try: lvs_video_understanding_tool = await builder.get_tool( config.lvs_video_understanding_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN ) except ValueError as e: logger.warning( f"Video Analysis Report: LVS tool '{config.lvs_video_understanding_tool}' not found, LVS features will be disabled: {e}" ) lvs_video_understanding_tool = None video_url_tool = None if config.video_url_tool: video_url_tool = await builder.get_tool(config.video_url_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) picture_url_tool = None if config.picture_url_tool: picture_url_tool = await builder.get_tool(config.picture_url_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) # Load HITL LLM if configured (for /generate and /refine commands) hitl_llm = None if config.hitl_prompt_llm: try: hitl_llm = await builder.get_llm(config.hitl_prompt_llm, wrapper_type=LLMFrameworkEnum.LANGCHAIN) logger.info(f"HITL LLM loaded: {config.hitl_prompt_llm}") except Exception as e: logger.warning(f"Failed to load HITL LLM '{config.hitl_prompt_llm}': {e}. AI prompt generation disabled.") hitl_llm = None # HITL state: maps thread_id -> vlm_prompt (persisted per conversation) # Uses OrderedDict as LRU cache to prevent unbounded memory growth. # # max_conversations: Maximum number of conversation states to retain. # - Each entry stores ~1-2KB (thread_id + prompt string) # - At 1000 conversations: ~1-2MB memory footprint # - Oldest entries are evicted when limit is exceeded (LRU policy) # - Operators can adjust this value based on expected concurrent users # and available memory. For high-traffic deployments, consider 500-2000. max_conversations = 1000 vlm_prompt_state: OrderedDict[str, str] = OrderedDict() def _store_prompt(thread_id: str, prompt: str) -> None: """Store a prompt for a thread, evicting oldest entries if over capacity.""" # If key exists, remove it first to update insertion order (LRU behavior) if thread_id in vlm_prompt_state: vlm_prompt_state.move_to_end(thread_id) vlm_prompt_state[thread_id] = prompt # Evict oldest entries if over capacity while len(vlm_prompt_state) > max_conversations: evicted_id, _ = vlm_prompt_state.popitem(last=False) logger.debug(f"Evicted prompt state for thread {evicted_id} (LRU capacity: {max_conversations})") def _get_prompt(thread_id: str) -> str | None: """Get a prompt for a thread, updating access order (LRU behavior).""" if thread_id in vlm_prompt_state: vlm_prompt_state.move_to_end(thread_id) return vlm_prompt_state[thread_id] return None # Default HITL template if not provided in config default_hitl_vlm_prompt_template = """**VLM Prompt for Report Generation** **OPTIONS:** • Press Submit (empty) → Approve and generate report • Type a new prompt → Use it directly • Type `/generate ` → AI creates a prompt based on your description • Type `/refine ` → AI modifies the current prompt • Type `/cancel` → Cancel report generation Enter your choice or press Submit to keep current value:""" async def _prompt_user_input(prompt_text: str, required: bool = True, placeholder: str = "") -> str | None: """Prompt user for input using HITL with option to cancel via /cancel. Args: prompt_text: The prompt text to show to the user required: Whether the input is required placeholder: Placeholder text for the input field Returns: str: User's input text, or None if user cancelled """ nat_context = Context.get() user_input_manager = nat_context.user_interaction_manager human_prompt = HumanPromptText(text=prompt_text, required=required, placeholder=placeholder) response: InteractionResponse = await user_input_manager.prompt_user_input(human_prompt) # Check if user cancelled - content will be None when cancelled if response.content is None: logger.info("User cancelled HITL prompt") return None # Check if content.text is None (another possible cancel indicator) if hasattr(response.content, "text") and response.content.text is None: logger.info("User cancelled HITL prompt") return None # Return raw text (no strip) so caller can treat only truly empty input as "approve default" return response.content.text # type: ignore def _wrap_text_at_words(text: str, words_per_line: int = 12) -> str: """ Insert newlines to wrap text at approximately the specified number of words per line. Preserves existing newlines and only wraps within continuous text segments. Args: text: The text to wrap words_per_line: Number of words before inserting a newline (default: 12) Returns: str: Text with newlines inserted for wrapping """ if not text: return text # Split by existing newlines to preserve them lines = text.split("\n") wrapped_lines = [] for line in lines: if not line.strip(): # Preserve empty lines wrapped_lines.append(line) continue words = line.split() if len(words) <= words_per_line: wrapped_lines.append(line) continue # Wrap long lines current_line_words = [] for word in words: current_line_words.append(word) if len(current_line_words) >= words_per_line: wrapped_lines.append(" ".join(current_line_words)) current_line_words = [] # Add remaining words if current_line_words: wrapped_lines.append(" ".join(current_line_words)) return "\n".join(wrapped_lines) async def _llm_generate_prompt(description: str) -> str: """Generate a VLM prompt using LLM based on user's description. Raises: ValueError: If LLM is not configured or if LLM call/response processing fails. """ if not hitl_llm: raise ValueError("AI prompt generation not available. Configure hitl_prompt_llm in config.") from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage messages = [ SystemMessage(content=config.hitl_generate_system_prompt), HumanMessage(content=description), ] # Call LLM with error handling try: response = await hitl_llm.ainvoke(messages) except Exception as e: logger.error(f"LLM prompt generation failed during ainvoke: {type(e).__name__}: {e}") raise ValueError(f"Failed to generate prompt: LLM call failed - {e}") from e # Process response with error handling - use shared reasoning parser try: _, generated = parse_reasoning_content(response) generated = (generated or "").strip() # Wrap text for better readability in UI generated = _wrap_text_at_words(generated) except Exception as e: logger.error(f"LLM prompt generation failed during response processing: {type(e).__name__}: {e}") raise ValueError(f"Failed to generate prompt: response processing failed - {e}") from e logger.info(f"LLM generated prompt: {generated[:100]}...") return generated async def _llm_refine_prompt(current_prompt: str, instructions: str) -> str: """Refine existing prompt using LLM based on user's instructions. Raises: ValueError: If LLM is not configured or if LLM call/response processing fails. """ if not hitl_llm: raise ValueError("AI prompt refinement not available. Configure hitl_prompt_llm in config.") from langchain_core.messages import HumanMessage from langchain_core.messages import SystemMessage # Replace {current_prompt} placeholder in system prompt system_prompt = config.hitl_refine_system_prompt.replace("{current_prompt}", current_prompt) messages = [ SystemMessage(content=system_prompt), HumanMessage(content=instructions), ] # Call LLM with error handling try: response = await hitl_llm.ainvoke(messages) except Exception as e: logger.error(f"LLM prompt refinement failed during ainvoke: {type(e).__name__}: {e}") raise ValueError(f"Failed to refine prompt: LLM call failed - {e}") from e # Process response with error handling - use shared reasoning parser try: _, refined = parse_reasoning_content(response) refined = (refined or "").strip() # Wrap text for better readability in UI refined = _wrap_text_at_words(refined) except Exception as e: logger.error(f"LLM prompt refinement failed during response processing: {type(e).__name__}: {e}") raise ValueError(f"Failed to refine prompt: response processing failed - {e}") from e logger.info(f"LLM refined prompt: {refined[:100]}...") return refined async def _collect_hitl_vlm_prompt(current_prompt: str | None) -> str | None: """ Collect/confirm VLM prompt via HITL with support for /generate and /refine commands. Flow: 1. Show current prompt 2. User can: approve (empty), edit directly, /generate, /refine, or /cancel 3. If /generate or /refine, show result and loop for approval 4. Plain text or empty = final answer (no loop) Args: current_prompt: Current prompt from state (if any) Returns: str: The confirmed or updated VLM prompt, or None if cancelled """ logger.info("Starting HITL VLM prompt collection workflow") hitl_template = config.hitl_vlm_prompt_template or default_hitl_vlm_prompt_template # Track the working prompt and its source working_prompt = current_prompt or config.vlm_prompt prompt_source = "CURRENTLY SET" if current_prompt else "DEFAULT" error_message = "" # Error message to display to user (cleared after each prompt) while True: # Build the display text, including any error message from previous iteration if error_message: prompt_text = f"**⚠️ ERROR:** {error_message}\n\n**{prompt_source}:**\n```\n{working_prompt}\n```\n\n{hitl_template}" error_message = "" # Clear after displaying else: prompt_text = f"**{prompt_source}:**\n```\n{working_prompt}\n```\n\n{hitl_template}" user_input = await _prompt_user_input( prompt_text, required=False, placeholder="Enter prompt, /generate, /refine, /cancel, or press Submit to approve", ) # User clicked Cancel button if user_input is None: logger.info("User cancelled report generation") return None # Only truly empty input = approve (do not strip before check; space-only is not approval) if user_input == "": logger.info(f"User approved {prompt_source.lower()} prompt") return working_prompt stripped = user_input.strip() # Handle /cancel command if stripped.lower() == "/cancel": logger.info("User cancelled report generation via /cancel command") return None # Handle /generate command if stripped.lower().startswith("/generate "): description = stripped[10:].strip() if not description: logger.warning("Empty description for /generate, prompting again") error_message = "Please provide a description after /generate" continue try: working_prompt = await _llm_generate_prompt(description) prompt_source = "AI-GENERATED" continue # Loop to show generated prompt for approval except ValueError as e: logger.error(f"Failed to generate prompt: {e!s}") error_message = f"Failed to generate prompt: {e!s}" continue # Handle /refine command if stripped.lower().startswith("/refine "): instructions = stripped[8:].strip() if not instructions: logger.warning("Empty instructions for /refine, prompting again") error_message = "Please provide instructions after /refine" continue try: working_prompt = await _llm_refine_prompt(working_prompt, instructions) prompt_source = "AI-REFINED" continue # Loop to show refined prompt for approval except ValueError as e: logger.error(f"Failed to refine prompt: {e!s}") error_message = f"Failed to refine prompt: {e!s}" continue # Whitespace-only = not valid; re-prompt if not stripped: error_message = ( "Input is empty or whitespace. Press Submit with no text to approve the default, or enter a prompt." ) continue # Plain text = use directly (no further approval needed) logger.info(f"User provided custom prompt: {stripped[:100]}...") return stripped async def _video_report_gen(report_input: VideoReportGenInput) -> VideoReportGenOutput: """ Generate a video analysis report for uploaded videos (Video(uploaded) Report mode). This tool: 1. Sanitizes VLM prompts (removes SOM markers) 2. Calls video_understanding tool for each prompt 3. Formats results using optional template and LLM 4. Saves markdown and PDF to object store 5. Returns URLs and metadata """ logger.info(f"Generating report for sensor '{report_input.sensor_id}'") logger.info(f"User query: {report_input.user_query}") # Decide which video understanding tool to use based on user's explicit request selected_tool = video_understanding_tool # Default to regular tool tool_name = "video_understanding" lvs_fallback_warning = "" # Use LVS only if explicitly requested by user # based on config, use lvs if video duration is longer than config.lvs_video_length stream_id = await get_stream_id(report_input.sensor_id, config.vst_internal_url) start_timestamp, end_timestamp = await get_timeline(stream_id, config.vst_internal_url) start_dt = iso8601_to_datetime(start_timestamp) end_dt = iso8601_to_datetime(end_timestamp) duration_seconds = (end_dt - start_dt).total_seconds() if duration_seconds > config.lvs_video_length: if lvs_video_understanding_tool is not None: selected_tool = lvs_video_understanding_tool tool_name = "lvs_video_understanding" logger.info(f"Using LVS tool (video duration {duration_seconds:.1f}s > {config.lvs_video_length}s)") else: logger.warning( "Video Analysis Report: LVS tool is not configured. " f"Falling back to standard video_understanding tool. for video duration {duration_seconds:.1f}s > {config.lvs_video_length}s" ) lvs_fallback_warning = ( f"⚠️ **Note:** Input video {report_input.sensor_id} is {duration_seconds:.1f}s long. \n" f"Please use Long video Summarization' for videos longer than {config.lvs_video_length}s.\n\n" ) else: logger.info( f"Using standard video_understanding tool (video duration {duration_seconds:.1f}s <= {config.lvs_video_length}s)" ) # Step 2: Determine prompt and chunks based on tool selection chunks: list[tuple[float, float]] | None = None # Only used for standard VLM clean_prompt = None # Track the VLM prompt for report header if tool_name == "lvs_video_understanding": # LVS tool manages its own prompts via HITL - no chunking needed logger.info("Using LVS tool (prompts managed by HITL workflow)") # Step 3: Run LVS analysis on entire video vlm_input: dict[str, str | bool] = { "sensor_id": report_input.sensor_id, } # Add vlm_reasoning if specified if report_input.vlm_reasoning is not None: vlm_input["vlm_reasoning"] = report_input.vlm_reasoning try: vlm_results = [await selected_tool.ainvoke(input=vlm_input)] except Exception as e: logger.exception(f"Video Analysis Report: Failed to run LVS analysis: {e}") raise ValueError( f"Video Analysis Report: Failed to analyze video '{report_input.sensor_id}': {e}" ) from e else: # Standard VLM: divide video into chunks and process in parallel # HITL: Collect/confirm VLM prompt if enabled if config.hitl_enabled: thread_id = ContextState.get().conversation_id.get() current_prompt = _get_prompt(thread_id) resolved_prompt = await _collect_hitl_vlm_prompt(current_prompt) # Check if user cancelled if resolved_prompt is None: logger.info("Report generation cancelled by user") return VideoReportGenOutput( summary="Report generation was cancelled by the user.", http_url=None, pdf_url=None, object_store_key=None, file_size=0, pdf_file_size=0, content=None, video_url=None, ) _store_prompt(thread_id, resolved_prompt) logger.info(f"[PROMPT LOADED] video_report_gen.vlm_prompt from HITL: '{resolved_prompt[:100]}...'") clean_prompt = _remove_som_markers(resolved_prompt) else: logger.info(f"[PROMPT LOADED] video_report_gen.vlm_prompt from CONFIG: '{config.vlm_prompt[:100]}...'") clean_prompt = _remove_som_markers(config.vlm_prompt) stream_id = await get_stream_id(report_input.sensor_id, config.vst_internal_url) start_timestamp, end_timestamp = await get_timeline(stream_id, config.vst_internal_url) start_dt = iso8601_to_datetime(start_timestamp) end_dt = iso8601_to_datetime(end_timestamp) duration_seconds = (end_dt - start_dt).total_seconds() if duration_seconds > config.max_duration_for_chunking: # Video too long for chunking - process as single chunk logger.warning( f"Video duration ({duration_seconds:.1f}s) exceeds chunking threshold ({config.max_duration_for_chunking}s). " f"Processing entire video as single chunk. Quality may be degraded." ) chunks = [(0.0, duration_seconds)] else: # Divide video into chunks chunks = _divide_video_into_chunks( duration_seconds, config.chunk_duration_seconds, ) logger.info(f"Divided video into {len(chunks)} chunks of {config.chunk_duration_seconds}s each") # Step 3: Run VLM analysis tasks in parallel (one per chunk) logger.info(f"Running {len(chunks)} VLM analysis tasks with {tool_name}") # FIX: The video understanding tool has two input modes: # - stream_mode=true -> VideoUnderstandingInput (start_timestamp: str, ISO 8601) # - stream_mode=false -> VideoUnderstandingInputNonStream (start_timestamp: float, seconds offset) # Previously, ISO strings were always passed regardless of the tool's mode, # which caused a "could not convert string to float" validation error when # the tool was configured with stream_mode=false (e.g. dev-profile-base). # We now inspect the tool's input schema to detect which format it expects. uses_float_timestamps = True tool_args_schema = getattr(selected_tool, "args_schema", None) if tool_args_schema and hasattr(tool_args_schema, "model_fields"): ts_field = tool_args_schema.model_fields.get("start_timestamp") if ts_field: field_type = ts_field.annotation uses_float_timestamps = field_type is float or ( hasattr(field_type, "__args__") and float in field_type.__args__ ) chunk_process_start_time = datetime.now() vlm_tasks = [] for chunk_idx, (chunk_start, chunk_end) in enumerate(chunks): vlm_prompt = ( clean_prompt + "\n\n" + CHUNK_TIMESTAMP_PROMPT.format(start_time=0, end_time=chunk_end - chunk_start) ) if uses_float_timestamps: # Non-stream mode: pass float offsets (seconds since beginning of stream) chunk_vlm_input: dict[str, Any] = { "sensor_id": report_input.sensor_id, "start_timestamp": chunk_start, "end_timestamp": chunk_end, "user_prompt": vlm_prompt, } else: # Stream mode: convert chunk offsets to ISO timestamp strings chunk_start_dt = start_dt + timedelta(seconds=chunk_start) chunk_end_dt = start_dt + timedelta(seconds=chunk_end) chunk_vlm_input = { "sensor_id": report_input.sensor_id, "start_timestamp": datetime_to_iso8601(chunk_start_dt), "end_timestamp": datetime_to_iso8601(chunk_end_dt), "user_prompt": vlm_prompt, } # Add vlm_reasoning if specified if report_input.vlm_reasoning is not None: chunk_vlm_input["vlm_reasoning"] = report_input.vlm_reasoning logger.info(f"Chunk {chunk_idx + 1}/{len(chunks)}: {chunk_start} to {chunk_end}") vlm_tasks.append(selected_tool.ainvoke(input=chunk_vlm_input)) try: vlm_results = await asyncio.gather(*vlm_tasks) chunk_process_elapsed = (datetime.now() - chunk_process_start_time).total_seconds() logger.info( f"Successfully completed {len(vlm_results)} VLM chunk analyses in {chunk_process_elapsed:.2f}s" ) except Exception as e: logger.exception(f"Video Analysis Report: Failed to run VLM analysis: {e}") raise ValueError( f"Video Analysis Report: Failed to analyze video '{report_input.sensor_id}': {e}" ) from e # Step 4: Create report with header and VLM analysis logger.info(f"Processing {tool_name} response") # Extract HITL prompts if using LVS (needed for header) hitl_prompts = None if tool_name == "lvs_video_understanding" and vlm_results: try: # Parse the first LVS result to extract HITL prompts lvs_data = json.loads(vlm_results[0]) # Check if LVS was aborted by user if lvs_data.get("status") == LVSStatus.ABORTED.value: logger.info("LVS analysis was aborted by user, returning aborted message") return VideoReportGenOutput( http_url=None, summary=lvs_data.get("message", "Video analysis was cancelled by user."), ) hitl_prompts = lvs_data.get("hitl_prompts") except Exception as e: logger.warning(f"Failed to extract HITL prompts from LVS response: {e}") report_header = _create_report_header( report_input.sensor_id, report_input.user_query, hitl_prompts=hitl_prompts, ) # Format results based on tool type if tool_name == "lvs_video_understanding": # Format LVS responses (video clip links will be injected later) vlm_content = "\n\n".join([_format_lvs_response(result) for result in vlm_results]) else: # Normalize timestamps in each chunk result and combine # VLM returns timestamps relative to chunk (starting from 0s) # We need to offset them by chunk_start to get absolute video time assert chunks is not None # chunks is always set for non-LVS tools normalized_results = [] for (chunk_start, chunk_end), result in zip(chunks, vlm_results, strict=True): cleaned = _clean_vlm_response(result) if config.normalize_timestamps: normalized = _normalize_chunk_timestamps(cleaned, chunk_start, chunk_end) else: normalized = cleaned # Filter out short duration events from markdown filtered = _filter_short_duration_from_markdown(normalized, min_duration_seconds=2.0) normalized_results.append(filtered) vlm_content = "\n\n".join(normalized_results) # Step 4b: Inject snapshots for timestamps found in VLM response if picture_url_tool: vlm_content = await _inject_snapshots( vlm_content, report_input.sensor_id, picture_url_tool, ) # Step 4c: Inject video clip links for timestamps found in VLM response if config.vst_internal_url and config.vst_external_url: vlm_content = await _inject_video_clips( vlm_content, report_input.sensor_id, config.vst_internal_url, config.vst_external_url, ) markdown_content = report_header + vlm_content # Step 5: Fetch video URL video_url = None if video_url_tool: try: video_result = await video_url_tool.ainvoke( input={ "sensor_id": report_input.sensor_id, } ) video_url = video_result.video_url logger.info(f"Video URL: {video_url}") except Exception as e: logger.warning(f"Video Analysis Report: Failed to fetch video URL: {e}") # Append video URL to report # FIX: The URL is placed in its own paragraph (separated by \n\n) instead of # inline with the label. When both were on the same line, the CSS # text-align:justify caused xhtml2pdf to stretch the space between "Video # Playback:" and the URL across the full page width in the PDF output. if video_url: markdown_content += "\n\n## Resources\n\n" markdown_content += f"**Video Playback:**\n\n{video_url}\n\n" # Step 6: Save reports to object store timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"vss_report_{timestamp_str}.md" pdf_filename = filename.replace(".md", ".pdf") # Save markdown http_url, file_size = await _save_markdown_to_object_store(markdown_content, filename, object_store, config) # Save PDF pdf_url, pdf_file_size = await _save_pdf_to_object_store( markdown_content, filename, pdf_filename, object_store, config ) # Step 7: Create summary summary = "" if lvs_fallback_warning: summary += lvs_fallback_warning summary += f"Report generated for '{report_input.sensor_id}'.\n\n" logger.info(f"report generation complete: {http_url}") return VideoReportGenOutput( http_url=http_url, pdf_url=pdf_url, object_store_key=filename, summary=summary, file_size=file_size, pdf_file_size=pdf_file_size, content=markdown_content, video_url=video_url, hitl_prompts=hitl_prompts, ) desc = _video_report_gen.__doc__ if _video_report_gen.__doc__ is not None else "" if config.lvs_video_understanding_tool is not None: desc += f"\nlvs is available. report agent will call lvs to generate a report for videos longer than {config.lvs_video_length}s.\n\n" function_info = FunctionInfo.create( single_fn=_video_report_gen, description=desc, input_schema=VideoReportGenInput, single_output_schema=VideoReportGenOutput, ) yield function_info ================================================ FILE: agent/src/vss_agents/tools/video_skim_caption.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from pydantic import model_validator logger = logging.getLogger(__name__) class VideoSkimCaptionConfig(FunctionBaseConfig, name="video_skim_caption"): """Configuration for the Video Skim Caption tool.""" skim_fps: float = Field( 0.5, description="The fixed fps to sample the video when skimming long videos.", ) class VideoSkimCaptionInput(BaseModel): """Input for the Video Skim Caption tool""" filename: str = Field( ..., description="The filename of the video to caption (e.g., 'camera1.mp4').", ) start_timestamp: float = Field( ..., description="The start timestamp in pts of the video to understand", ) end_timestamp: float = Field( ..., description="The end timestamp in pts of the video to understand", ) user_prompt: str = Field( ..., description="The prompt that is used to query the VLM to understand the video, mention all search entities in the prompt that is related to the user's query.", ) video_duration: float = Field( ..., description="The duration of the video in seconds", ) model_config = { "extra": "forbid", } @model_validator(mode="before") @classmethod def validate_end_timestamp(cls, info: dict) -> dict: if info["video_duration"] <= 0: raise ValueError(f"Video duration must be positive, got {info['video_duration']}") if info["end_timestamp"] is None or info["end_timestamp"] > info["video_duration"]: # Subtract small epsilon to avoid MoviePy precision issues when end_timestamp equals video_duration info["end_timestamp"] = info["video_duration"] - 0.01 return info @register_function(config_type=VideoSkimCaptionConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def video_skim_caption(config: VideoSkimCaptionConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: async def _video_skim_caption(video_skim_caption_input: VideoSkimCaptionInput) -> str: """ This tool uses the VLM to skim a long video clip from start_timestamp to end_timestamp. video clip is sampled at a lower fps - frames per second. IMPORTANT: - This tool is slow and expensive, only use it when necessary. - In the prompt, don't add timestamp, instead, use the start_timestamp and end_timestamp to indicate the time range of the video clip. - In the prompt, don't ask to **identify** an individual or any PII type of query, instead ask to create general descriptions about the people(attire, gender, location, etc), objects, and actions. Input: video_skim_caption_input: VideoSkimCaptionInput Returns: str: The caption for the video. """ # Create a VideoCaptionInput object and call video caption tool video_caption_input = { "filename": video_skim_caption_input.filename, "start_timestamp": video_skim_caption_input.start_timestamp, "end_timestamp": video_skim_caption_input.end_timestamp, "user_prompt": video_skim_caption_input.user_prompt, "fps": config.skim_fps, "video_duration": video_skim_caption_input.video_duration, } # Call video caption tool video_caption_tool = await builder.get_tool("video_caption", wrapper_type=LLMFrameworkEnum.LANGCHAIN) try: ret_str: str = await video_caption_tool.ainvoke(video_caption_input) except Exception as e: logger.error(f"Error calling video_caption_tool: {e}") logger.error(f"Error type: {type(e)}") raise e return str(ret_str) yield FunctionInfo.create( single_fn=_video_skim_caption, description=_video_skim_caption.__doc__, input_schema=VideoSkimCaptionInput, single_output_schema=str, ) ================================================ FILE: agent/src/vss_agents/tools/video_understanding.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 from collections.abc import AsyncGenerator from datetime import datetime from datetime import timedelta import logging import tempfile from typing import Any from typing import Literal import aiohttp import boto3 from langchain_core.messages import HumanMessage from langchain_core.prompts import ChatPromptTemplate from langchain_core.prompts import MessagesPlaceholder from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.component_ref import LLMRef from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from pydantic import model_validator from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import get_stream_id from vss_agents.utils.frame_select import frame_select from vss_agents.utils.reasoning_parsing import parse_content_blocks from vss_agents.utils.retry import create_retry_strategy from vss_agents.utils.url_translation import translate_url logger = logging.getLogger(__name__) def _parse_thinking_from_content(content: str) -> tuple[str | None, str]: """ Parse thinking content from VLM responses that use and tags. Args: content: The VLM response content Returns: tuple[str | None, str]: (thinking_content, answer_content) """ if not content: return None, content # Check for tags if "" in content and "" in content: think_start = content.find("") think_end = content.find("") if think_start != -1 and think_end != -1 and think_start < think_end: thinking = content[think_start + len("") : think_end].strip() # Extract answer part after after_think = content[think_end + len("") :].strip() # Check if there's an tag if "" in after_think and "" in after_think: answer_start = after_think.find("") answer_end = after_think.find("") answer = after_think[answer_start + len("") : answer_end].strip() else: # No tag, use everything after answer = after_think return thinking, answer # No thinking tags found, return original content return None, content class VideoUnderstandingConfig(FunctionBaseConfig, name="video_understanding"): """Configuration for the Video Understanding tool.""" vlm_name: LLMRef = Field( ..., description="The name of the LLM to use for the image caption tool.", ) minio_url: str = Field( "http://localhost:9000", description="The endpoint URL of the MinIO server", ) access_key: str = Field( "minioadmin", description="The access key of the S3 bucket", ) secret_key: str = Field( "minioadmin", description="The secret key of the S3 bucket", ) bucket_name: str = Field( "my-bucket", description="The name of the S3 bucket to use for video storage", ) max_frames: int = Field( 24, description="The maximum number of frames to sample from the video", ) max_fps: int = Field( default=2, description="Maximum frames per second to sample. num_frames = min(video_length * max_fps, max_frames)", ) min_pixels: int = Field( 1568, description="The minimum number of pixels for 2 frames from the video, 28x28=784 will be converted to one video token", ) max_pixels: int = Field( 345600, description="The maximum number of pixels for 2 frames from the video, 28x28=784 will be converted to one video token", ) reasoning: bool = Field( False, description="Only for cosmos reason models, turn on reasoning when you want to let the VLM reason before returning the answer.", ) filter_thinking: bool = Field( False, description="Whether to filter out thinking traces from the VLM response. When enabled, only the answer portion is returned.", ) use_vst: bool = Field( True, description="Whether to use VST service to get the video URL. If False, it will use the MinIO service to get the video URL.", ) time_format: Literal["iso", "offset"] = Field( "iso", description="Timestamp input format: 'iso' for ISO 8601 UTC strings (e.g. '2025-08-25T03:05:55Z'), " "'offset' for seconds since stream start. " "Must match across video_understanding, vst.video_clip, vst.snapshot, and critic_agent configs.", ) video_url_tool: str | None = Field( None, description="A tool to be used to get the video URL by sensor ID and timestamp(default to use VST service)", ) use_base64: bool = Field( False, description="Whether to use base64 encoding to send the video to the VLM. If True, the video will be encoded to base64 and sent to the VLM.", ) system_prompt: str | None = Field( default=None, description="Optional custom system prompt for the VLM. If not provided, uses default reasoning prompt when reasoning=True, or no system prompt when reasoning=False.", ) # URL translation configuration for VLM vlm_mode: str | None = Field( default="local", description="VLM mode: 'remote' (VLM is external, needs public URLs), 'local' or 'local_shared' (VLM is local, needs internal URLs)", ) internal_ip: str | None = Field( default="", description="Internal IP / docker host IP for URL translation", ) external_ip: str | None = Field( default="", description="Public IP accessible from the internet for URL translation", ) vst_internal_url: str | None = Field( default=None, description="Internal VST base URL (e.g., 'http://HOST_IP:30888'). " "Used for URL translation when behind a reverse proxy.", ) class VideoUnderstandingInput(BaseModel): """Input for the Video Caption tool""" sensor_id: str = Field( ..., description="The sensor ID or the name of the video file in VST to understand", min_length=1, ) start_timestamp: str = Field( ..., description="The start timestamp in UTC ISO 8601 format (e.g., '2025-08-25T03:05:55.752Z')", ) end_timestamp: str = Field( ..., description="The end timestamp in UTC ISO 8601 format (e.g., '2025-08-25T03:06:15.752Z')", ) user_prompt: str = Field( ..., description="The prompt that is used to query the VLM to understand the video, mention all search entities in the prompt that is related to the user's query.", min_length=1, ) object_ids: list[str] | None = Field( None, description="Optional list of object IDs to display as overlays in the video (e.g., from incident objectIds or info.primaryObjectId)", ) vlm_reasoning: bool | None = Field( default=None, description="Enable VLM reasoning mode. If None, uses config.reasoning default.", ) model_config = { "extra": "forbid", } class VideoUnderstandingOffsetInput(BaseModel): """Input for the Video Understanding tool (offset mode). start_timestamp and end_timestamp are floats representing seconds since the beginning of the stream. """ sensor_id: str = Field( ..., description="The sensor ID or the name of the video file in VST to understand", min_length=1, ) start_timestamp: float | None = Field( None, description="Optional start time offsets (in seconds since beginning of the stream), if None, then the entire stream is returned", ) end_timestamp: float | None = Field( None, description="Optional end time offsets (in seconds since beginning of the stream), if None, then the entire stream is returned", ) user_prompt: str = Field( ..., description="The prompt that is used to query the VLM to understand the video, mention all search entities in the prompt that is related to the user's query.", min_length=1, ) vlm_reasoning: bool | None = Field( default=None, description="Enable VLM reasoning mode. If None, uses config.reasoning default.", ) model_config = { "extra": "forbid", } @model_validator(mode="before") @classmethod def validate_start_and_end_time(cls, info: dict) -> dict: start = info.get("start_timestamp") end = info.get("end_timestamp") if start is not None: start = float(start) if start < 0: raise ValueError("Start time offset must be non-negative") info["start_timestamp"] = start if end is not None: end = float(end) if end < 0: raise ValueError("End time offset must be non-negative") info["end_timestamp"] = end if start is not None and end is not None and start >= end: raise ValueError("Start time offset must be before end time offset") return info def extend_timestamp(start_time: str, end_time: str) -> str: start_time_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) end_time_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00")) video_duration = (end_time_dt - start_time_dt).total_seconds() # Ensure at least 1 second duration if video_duration < 1.0: end_time_dt = start_time_dt + timedelta(seconds=1.0) # Always return ISO format string return end_time_dt.isoformat().replace("+00:00", "Z") async def _build_vlm_messages( video_url: str, user_prompt: str, *, use_frame_images: bool, use_base64: bool, video_length_seconds: float, num_frames: int, max_fps: int, ) -> list[HumanMessage]: """Download/transform video and build VLM messages for the appropriate backend.""" if use_frame_images: timeout = aiohttp.ClientTimeout(total=300) async with aiohttp.ClientSession(timeout=timeout) as session, session.get(video_url) as resp: resp.raise_for_status() video_data = await resp.read() with tempfile.NamedTemporaryFile(suffix=".mp4", delete=True) as tmp: tmp.write(video_data) tmp.flush() step_size = max(video_length_seconds / num_frames, 1.0 / max_fps) base64_frames = frame_select(tmp.name, 0.0, video_length_seconds, step_size) return [ HumanMessage( content=[ { "type": "text", "text": f"The following images are a sequence of frames from a video. Answer the user's question based on the video: {user_prompt}", }, *[ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{frame}"}} for frame in base64_frames ], ] ) ] if use_base64: timeout = aiohttp.ClientTimeout(total=300) async with aiohttp.ClientSession(timeout=timeout) as session, session.get(video_url) as resp: resp.raise_for_status() video_data = await resp.read() video_base64 = base64.b64encode(video_data).decode("utf-8") video_url = f"data:video/mp4;base64,{video_base64}" return [ HumanMessage( content=[ {"type": "text", "text": user_prompt}, {"type": "video_url", "video_url": {"url": video_url}}, ] ) ] @register_function(config_type=VideoUnderstandingConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def video_understanding(config: VideoUnderstandingConfig, builder: Builder) -> AsyncGenerator[FunctionInfo]: base_vlm = await builder.get_llm(config.vlm_name, wrapper_type=LLMFrameworkEnum.LANGCHAIN) is_nim = config.vlm_name.startswith("nim_") model_name = getattr(base_vlm, "model_name", "") or getattr(base_vlm, "model", "") is_cosmos_model = is_nim and "cosmos" in model_name is_cosmos_reason2 = is_nim and model_name == "nvidia/cosmos-reason2-8b" # Dynamically determine if we extract frames for this model (only needed for official OpenAI endpoints for now) # Supposes any vlm_name prefixed with "openai_" is from an official OpenAI endpoint use_frame_images = str(config.vlm_name).startswith("openai_") logger.info( f"Using VLM profile: {config.vlm_name}, use_frame_images: {use_frame_images}, use_base64: {config.use_base64}" ) if not config.use_vst: s3_client = boto3.client( "s3", endpoint_url=config.minio_url, aws_access_key_id=config.access_key, aws_secret_access_key=config.secret_key, region_name="us-east-1", verify=True, ) else: s3_client = None # VLM prompt templates setup if config.system_prompt: # Use custom system prompt from config logger.info(f"Using custom system prompt: {config.system_prompt[:100]}...") reasoning_prompt_template = ChatPromptTemplate.from_messages( [ ( "system", f"{config.system_prompt}\n\nWrap your response in the following format:\n\nyour reasoning\n\n\n\nyour answer following the observation format above\n", ), MessagesPlaceholder(variable_name="messages"), ] ) non_reasoning_prompt_template = ChatPromptTemplate.from_messages( [ ("system", config.system_prompt), MessagesPlaceholder(variable_name="messages"), ] ) else: # Use default prompts reasoning_prompt_template = ChatPromptTemplate.from_messages( [ ( "system", "Answer the question in the following format: \nyour reasoning\n\n\n\nyour answer\n.", ), MessagesPlaceholder(variable_name="messages"), ] ) non_reasoning_prompt_template = ChatPromptTemplate.from_messages( [ MessagesPlaceholder(variable_name="messages"), ] ) # For cosmos-reason2-8b: override to use no system prompt for the reasoning instructions (reasoning instructions are appended to user message) if is_cosmos_reason2: reasoning_prompt_template = non_reasoning_prompt_template async def _video_understanding( video_understanding_input: VideoUnderstandingInput | VideoUnderstandingOffsetInput, ) -> str: """ This tool uses the VLM to understand a video clip from start_timestamp to end_timestamp. IMPORTANT: - start_timestamp MUST be smaller than end_timestamp Returns: str: The caption for the video. """ # Calculate video length and dynamic num_frames if config.video_url_tool: vst_video_url = await builder.get_tool(config.video_url_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN) else: vst_video_url = None if config.use_vst: raise ValueError("video_url_tool is not configured and use_vst is True") if ( video_understanding_input.start_timestamp is not None and video_understanding_input.end_timestamp is not None ): if config.time_format == "iso": # "iso" mode: timestamps are already ISO 8601 strings — parse directly. start_ts = str(video_understanding_input.start_timestamp) end_ts = str(video_understanding_input.end_timestamp) start_dt = datetime.fromisoformat(start_ts.replace("Z", "+00:00")) end_dt = datetime.fromisoformat(end_ts.replace("Z", "+00:00")) else: # "offset" mode: timestamps are seconds since start of stream. # Fetch the stream timeline and add the offset to compute absolute datetimes. stream_id = await get_stream_id(video_understanding_input.sensor_id) start_iso, end_iso = await get_timeline(stream_id) start_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00")) + timedelta( seconds=float(video_understanding_input.start_timestamp) ) end_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00")) + timedelta( seconds=float(video_understanding_input.end_timestamp) ) else: # use entire video stream_id = await get_stream_id(video_understanding_input.sensor_id) start_iso, end_iso = await get_timeline(stream_id) start_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00")) end_dt = datetime.fromisoformat(end_iso.replace("Z", "+00:00")) video_length_seconds = (end_dt - start_dt).total_seconds() num_frames = min(int(video_length_seconds) * config.max_fps, config.max_frames) # Ensure at least 1 frame··· num_frames = max(num_frames, 1) logger.info( f"Video length: {video_length_seconds:.1f}s, num_frames: {num_frames} (max_fps={config.max_fps}, max_frames={config.max_frames})" ) # Bind VLM with dynamic num_frames if is_cosmos_model: media_io_kwargs = {"video": {"num_frames": num_frames}} if is_cosmos_reason2: mm_processor_kwargs = {"size": {"shortest_edge": config.min_pixels, "longest_edge": config.max_pixels}} else: mm_processor_kwargs = { "videos_kwargs": {"min_pixels": config.min_pixels, "max_pixels": config.max_pixels} } vlm = base_vlm.bind( mm_processor_kwargs=mm_processor_kwargs, media_io_kwargs=media_io_kwargs, ) else: vlm = base_vlm # Select reasoning mode: default to config.reasoning if not specified in the input use_reasoning = ( video_understanding_input.vlm_reasoning if video_understanding_input.vlm_reasoning is not None else config.reasoning ) if use_frame_images: # OpenAI models (reasoning configuration through parameters) if use_reasoning: vlm = vlm.bind(reasoning={"effort": "medium", "summary": "auto"}) prompt_template = non_reasoning_prompt_template else: prompt_template = reasoning_prompt_template if use_reasoning else non_reasoning_prompt_template vlm_chain = prompt_template | vlm logger.info(f"VLM reasoning mode: {use_reasoning}, use_frame_images: {use_frame_images}") # Step 1: Get the video URL (different paths for S3 vs VST) if not config.use_vst: # get the video URL from S3 if not s3_client: raise ValueError("S3 client is not configured correctly") video_url = s3_client.generate_presigned_url( "get_object", Params={ "Bucket": config.bucket_name, "Key": video_understanding_input.sensor_id + ".mp4", }, ExpiresIn=3600, ) logger.info(f"Video URL from S3: {video_url}") else: if config.time_format == "iso": # "iso" mode: pass ISO 8601 timestamps directly to the video URL tool. logger.info( f"Using {config.video_url_tool} to get video URL for file {video_understanding_input.sensor_id} from {video_understanding_input.start_timestamp} to {video_understanding_input.end_timestamp}" ) video_understanding_input.end_timestamp = extend_timestamp( str(video_understanding_input.start_timestamp), str(video_understanding_input.end_timestamp) ) vst_video_url_args: dict[str, Any] = { "sensor_id": video_understanding_input.sensor_id, "start_time": video_understanding_input.start_timestamp, "end_time": video_understanding_input.end_timestamp, } if hasattr(video_understanding_input, "object_ids") and video_understanding_input.object_ids: vst_video_url_args["object_ids"] = video_understanding_input.object_ids logger.info(f"Passing object IDs to VST video URL: {video_understanding_input.object_ids}") logger.debug(f"VST video URL arguments: {vst_video_url_args}") vst_video_url_result = await vst_video_url.ainvoke(input=vst_video_url_args) video_url = vst_video_url_result.video_url else: # "offset" mode: pass second-based offsets to the video URL tool. vst_video_url_result = await vst_video_url.ainvoke( input={ "sensor_id": video_understanding_input.sensor_id, "start_time": video_understanding_input.start_timestamp, "end_time": video_understanding_input.end_timestamp, } ) video_url = vst_video_url_result.video_url logger.debug(f"Video URL from VST: {video_url}") # Translate URL for VLM based on vlm_mode: # - remote: INTERNAL_IP -> EXTERNAL_IP (VLM needs public URLs) # - local/local_shared: EXTERNAL_IP -> INTERNAL_IP (VLM needs internal URLs) video_url = translate_url( video_url, config.vlm_mode, config.internal_ip, config.external_ip, config.vst_internal_url, ) logger.info(f"[Video Understanding] VIDEO URL FOR VLM ANALYSIS: {video_url}") user_prompt = video_understanding_input.user_prompt if is_cosmos_reason2 and use_reasoning: user_prompt = user_prompt + ( "\n\nAnswer the question using the following format:\n\n" "\nYour reasoning.\n\n\n" "Write your final answer immediately after the tag." ) messages = await _build_vlm_messages( video_url, user_prompt, use_frame_images=use_frame_images, use_base64=config.use_base64, video_length_seconds=video_length_seconds, num_frames=num_frames, max_fps=config.max_fps, ) # Retry logic for VLM call async for retry in create_retry_strategy(retries=3, exceptions=(Exception,)): with retry: try: response = await vlm_chain.ainvoke({"messages": messages}) logger.debug(f"Response: {response}") break except Exception as e: logger.error(f"Error understanding video {video_understanding_input.sensor_id}: {e}") raise e if use_frame_images: # OpenAI models (output reasoning in content_blocks) reasoning, answer = parse_content_blocks(response) if reasoning or answer: content = f"{reasoning}{answer or ''}" if reasoning else (answer or "") else: content = str(response.content) if response.content is not None else "" else: content = str(response.content) if response.content is not None else "" # Filter thinking traces if config.filter_thinking: thinking, answer = _parse_thinking_from_content(content) if thinking: logger.info( f"Filtered out thinking trace ({len(thinking)} chars), returning answer ({len(answer)} chars)" ) return answer else: logger.info("No thinking traces found in response") return content # Register the tool with the appropriate input schema based on time_format: # - "offset": accepts float offsets (seconds since start of stream). # Use for uploaded video files where only relative position matters. # - "iso": accepts ISO 8601 UTC timestamp strings. # Use for RTSP live streams where events have real-world wall-clock times. # This must match the time_format of the video_url_tool (e.g. vst.video_clip) # and any caller such as critic_agent. if config.time_format == "offset": async def _video_understanding_offset(video_understanding_input: VideoUnderstandingOffsetInput) -> str: return await _video_understanding(video_understanding_input) input_desc = """ Input: sensor_id: The sensor ID or the name of the video file in VST to understand start_timestamp: The start timestamp in offset seconds since beginning of the stream end_timestamp: The end timestamp in offset seconds since beginning of the stream user_prompt: The prompt that is used to query the VLM to understand the video, mention all search entities in the prompt that is related to the user's query. vlm_reasoning: Enable VLM reasoning mode. If None, uses config.reasoning default. Note: start_timestamp and end_timestamp are optional. If None, then the entire stream is returned. """ yield FunctionInfo.create( single_fn=_video_understanding_offset, description=(_video_understanding.__doc__ or "") + input_desc, input_schema=VideoUnderstandingOffsetInput, single_output_schema=str, ) else: async def _video_understanding_iso(video_understanding_input: VideoUnderstandingInput) -> str: return await _video_understanding(video_understanding_input) input_desc = """ Input: sensor_id: The sensor ID or the name of the video file in VST to understand start_timestamp: The start timestamp in UTC ISO 8601 format (e.g., '2025-08-25T03:05:55.752Z') end_timestamp: The end timestamp in UTC ISO 8601 format (e.g., '2025-08-25T03:06:15.752Z') user_prompt: The prompt that is used to query the VLM to understand the video, mention all search entities in the prompt that is related to the user's query. vlm_reasoning: Enable VLM reasoning mode. If None, uses config.reasoning default. """ yield FunctionInfo.create( single_fn=_video_understanding_iso, description=(_video_understanding.__doc__ or "") + input_desc, input_schema=VideoUnderstandingInput, single_output_schema=str, ) ================================================ FILE: agent/src/vss_agents/tools/vss_summarize.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging import math from typing import Annotated from typing import Any import uuid from nat.builder.builder import Builder from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field from pydantic import model_validator from vss_agents.data_models.vss import MediaInfoOffset from vss_agents.prompt import INIT_SUMMARIZE_PROMPT from vss_agents.prompt import VLM_FORMAT_INSTRUCTION from vss_agents.prompt import VLM_PROMPT_EXAMPLES logger = logging.getLogger(__name__) class VSSSummarizeConfig(FunctionBaseConfig, name="vss_summarize"): """Configuration for the VSS Summarize tool.""" backend_url: str = Field( ..., description="The URL of the VSS backend.", ) vss_version: str = Field( "2.3.0", description="The version of the VSS backend.", ) conn_timeout_ms: int = Field( default=5000, description="The connection timeout in milliseconds.", ) read_timeout_ms: int = Field( default=360000, description="The read timeout in milliseconds.", ) max_concurrency: int = Field( default=4, description="The maximum number of concurrent requests to the VSS backend", ) model_config = ConfigDict(extra="forbid") max_num_frames_per_chunk: int = Field( default=8, description="The maximum number of frames to summarize in each chunk, default is 10", ) class VSSSummarizeInput(BaseModel): """Input for the VSS Summarize tool""" id: uuid.UUID | list[uuid.UUID] = Field( description="Unique ID or list of IDs of the file(s)/live-stream(s) to summarize", ) prompt: str = Field( ..., max_length=5000, description="Prompt for summary generation, include objects and events that user's query is about, this will instruct the VLM to generate a dense caption for each frame", examples=VLM_PROMPT_EXAMPLES, ) # chunk_duration: int = Field( # default=60, # examples=[60, 30, 20, 10, 5], # description=( # "Chunk videos into `chunkDuration` seconds, examples are 5, 10, 30, 60. smaller chunks will give more detailed captions, " # "however it will slow down the processing, choose a bigger chunk at the beginning then use a smaller chunk on a " # "second pass and limiting the video's start and end time by setting the media_info parameter" # ), # ge=0, # le=3600, # json_schema_extra={"format": "int32"}, # ) step_size: float | None = Field( default=None, ge=0.1, le=10, description="The step size for the sampling of frames, VLM usually works best with a step size around 1 second. Smaller step size will give more detailed captions, however it will slow down the processing.", ) video_duration: float = Field( ..., description="The duration of the entire video", ) media_info: Annotated[ MediaInfoOffset, Field( ..., description=("The offset of the video clip to summarize"), ), ] summary_aggregation_prompt: str = Field( INIT_SUMMARIZE_PROMPT["summary_aggregation_prompt"], description="The prompt for aggregating the summaries from batches of video chunks", ) caption_summarization_prompt: str = Field( INIT_SUMMARIZE_PROMPT["caption_summarization_prompt"], description="The prompt for summarizing a batch of video captions from video chunks", ) @model_validator(mode="before") @classmethod def validate_all(cls, data: dict) -> Any: """Validate the entire VSSSummarizeInput object""" if data.get("media_info") is None: data["media_info"] = MediaInfoOffset(start_offset=0, end_offset=int(data["video_duration"])) elif data["media_info"].end_offset > data["video_duration"]: data["media_info"].end_offset = int(data["video_duration"]) return data model_config = { "extra": "forbid", } class VSSSummarizeOutput(BaseModel): """Output for the VSS Summarize tool""" media_info: MediaInfoOffset = Field(..., description="The media info of the video") summary: str = Field(..., description="The summary of the video") step_size: float | None = Field(None, description="The step size of the sampling of frames, in seconds") def __str__(self) -> str: # return as a list item in a markdown list media_info_str = f"{self.media_info.start_offset} - {self.media_info.end_offset}" ret = f"- timestamp: {media_info_str}\n" ret += f"- step size: {self.step_size}\n" ret += f"- summary: {self.summary}\n" return ret @register_function(config_type=VSSSummarizeConfig) async def vss_summarize(config: VSSSummarizeConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: from aiohttp import ClientSession from aiohttp import ClientTimeout import requests try: response = requests.get(config.backend_url + "/models", timeout=10) if response.status_code != 200: raise RuntimeError(f"Failed to get model from VSS backend: {response.status_code} {response.text}") vss_internal_model = response.json()["data"][0]["id"] except Exception as e: logger.error("Error getting model from VSS backend: %s, backend_url: %s", e, config.backend_url) raise e conn_timeout = config.conn_timeout_ms / 1000 read_timeout = config.read_timeout_ms / 1000 session = ClientSession(timeout=ClientTimeout(connect=conn_timeout, total=read_timeout)) async def _vss_summarize(vss_summarize_input: VSSSummarizeInput) -> VSSSummarizeOutput: """ Use vss backend with the vision language model to understand and summarize a video clip. In the input, you should provide the prompt, make sure to include the objects and events that user's query is about, this will instruct the VLM to generate a dense caption for each frame. Note: this tool is slow and expensive, please use it only when necessary for more detailed information. Input: vss_summarize_input: VSSSummarizeInput Returns: str: The summary of the video. """ step_size = vss_summarize_input.step_size # adjust step size based on the max_concurrency media_info = vss_summarize_input.media_info voi_video_duration = media_info.end_offset - media_info.start_offset if step_size is None: # initial summary should use step size based on max_concurrency chunk_duration = math.ceil(voi_video_duration / config.max_concurrency) # minimum step size at the first pass is 1.0 second num_frames_per_chunk = min(config.max_num_frames_per_chunk, int(chunk_duration)) step_size = chunk_duration / num_frames_per_chunk else: num_frames_per_chunk = int(max(min(voi_video_duration / step_size, config.max_num_frames_per_chunk), 1)) chunk_duration = min(max(1, math.ceil(num_frames_per_chunk * step_size)), voi_video_duration) req_obj: dict[str, Any] = {} req_obj["id"] = str(vss_summarize_input.id) fps = 1 / step_size req_obj["prompt"] = ( vss_summarize_input.prompt + "\n" + VLM_FORMAT_INSTRUCTION + "\n" + f"Below are frames sampled from the same video clip at fps {fps}" ) req_obj["summarize"] = True req_obj["enable_chat"] = True # get model from vss backend list-models and use it for summarization req_obj["model"] = vss_internal_model req_obj["caption_summarization_prompt"] = vss_summarize_input.caption_summarization_prompt req_obj["summary_aggregation_prompt"] = vss_summarize_input.summary_aggregation_prompt # add padding instruction to the prompt req_obj["chunk_duration"] = chunk_duration req_obj["media_info"] = { "type": "offset", "start_offset": media_info.start_offset, "end_offset": media_info.end_offset, } req_obj["num_frames_per_chunk"] = num_frames_per_chunk summary = "" logger.info("Summarizing video with request: %s", req_obj) try: async with session.post(config.backend_url + "/summarize", json=req_obj) as response: if response.status != 200: raise RuntimeError(f"Failed to summarize: {response.status} {response.text}") response_json = await response.json() choices = response_json.get("choices", []) if choices: summary = choices[0].get("message", {}).get("content", "") logger.info("Summary: %s", summary) else: raise RuntimeError("No choices found in the response, response: %s", response_json) except RuntimeError as e: logger.exception("Summarization pipeline failed, error: %s", e) except Exception as e: logger.exception("Error calling vss summarize: %s", e) logger.info("Summary: %s", summary) return VSSSummarizeOutput(summary=summary, step_size=step_size, media_info=media_info) yield FunctionInfo.create( single_fn=_vss_summarize, description=_vss_summarize.__doc__, input_schema=VSSSummarizeInput, single_output_schema=VSSSummarizeOutput, ) ================================================ FILE: agent/src/vss_agents/tools/vst/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/tools/vst/duration.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import datetime import logging from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import get_stream_id logger = logging.getLogger(__name__) class VSTDurationConfig(FunctionBaseConfig, name="vst.duration"): """Configuration for the VST Duration tool.""" vst_internal_url: str = Field( ..., description="The internal VST URL for API calls (e.g., http://${INTERNAL_IP}:30888)", ) class VSTDurationInput(BaseModel): """Input for the VST Video URL tool""" sensor_id: str = Field( ..., description="The name or the stream ID of the video file uploaded", min_length=1, ) class VSTDurationOutput(BaseModel): """Output for the VST Duration tool""" duration: float = Field( ..., description="The duration of the video in seconds", ) @register_function(config_type=VSTDurationConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def vst_duration(config: VSTDurationConfig, _: Builder) -> AsyncGenerator[FunctionInfo]: async def _vst_duration(vst_duration_input: VSTDurationInput) -> VSTDurationOutput: """Get the duration of the video for `video_name`. Args: vst_duration_input: VSTDurationInput containing sensor_id Returns: VSTDurationOutput containing duration of the video """ stream_id = await get_stream_id(vst_duration_input.sensor_id, config.vst_internal_url) start_timestamp, end_timestamp = await get_timeline(stream_id, config.vst_internal_url) duration = ( datetime.datetime.fromisoformat(end_timestamp.replace("Z", "+00:00")) - datetime.datetime.fromisoformat(start_timestamp.replace("Z", "+00:00")) ).total_seconds() return VSTDurationOutput(duration=duration) yield FunctionInfo.create( single_fn=_vst_duration, description=_vst_duration.__doc__, input_schema=VSTDurationInput, single_output_schema=VSTDurationOutput, ) ================================================ FILE: agent/src/vss_agents/tools/vst/register.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from . import duration from . import sensor_list from . import snapshot from . import timeline from . import video_clip from . import video_list __all__ = [ "duration", "sensor_list", "snapshot", "timeline", "video_clip", "video_list", ] ================================================ FILE: agent/src/vss_agents/tools/vst/sensor_list.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """VST Sensor List tool - Direct API access to list available sensors.""" from collections.abc import AsyncGenerator import logging from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.tools.vst.utils import get_name_to_stream_id_map logger = logging.getLogger(__name__) class VSTSensorListConfig(FunctionBaseConfig, name="vst.sensor_list"): """Configuration for the VST Sensor List tool.""" vst_internal_url: str = Field( ..., description="The internal VST URL for making API requests (e.g., http://${INTERNAL_IP}:30888)", ) class VSTSensorListInput(BaseModel): """Input for the VST Sensor List tool (no parameters needed).""" pass class VSTSensorListOutput(BaseModel): """Output for the VST Sensor List tool.""" sensor_names: list[str] = Field( ..., description="List of available sensor names", ) @register_function(config_type=VSTSensorListConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def vst_sensor_list(config: VSTSensorListConfig, _: Builder) -> AsyncGenerator[FunctionInfo]: """VST Sensor List tool that returns available sensor names using direct VST API.""" async def _vst_sensor_list(input_data: VSTSensorListInput) -> VSTSensorListOutput: # noqa: ARG001 """ Get a list of available sensor names from VST. Returns: VSTSensorListOutput containing list of sensor names """ logger.info("Fetching sensor list from VST") name_to_stream_id = await get_name_to_stream_id_map(config.vst_internal_url) sensor_names = sorted(name_to_stream_id.keys()) logger.info(f"Found {len(sensor_names)} sensors: {sensor_names}") return VSTSensorListOutput(sensor_names=sensor_names) yield FunctionInfo.create( single_fn=_vst_sensor_list, description=_vst_sensor_list.__doc__, input_schema=VSTSensorListInput, single_output_schema=VSTSensorListOutput, ) ================================================ FILE: agent/src/vss_agents/tools/vst/snapshot.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """VST Snapshot tool - snapshot/picture URL tool with bounding box overlay support. Supports two timestamp formats controlled by config: - 'offset' format: start_time is a float (seconds since beginning of stream) - 'iso' format: start_time is an ISO 8601 UTC timestamp string """ from collections.abc import AsyncGenerator from datetime import datetime from datetime import timedelta import json import logging from typing import Literal import urllib.parse import aiohttp from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import VSTError from vss_agents.tools.vst.utils import build_overlay_config from vss_agents.tools.vst.utils import get_stream_id from vss_agents.utils.retry import create_retry_strategy logger = logging.getLogger(__name__) def build_screenshot_url(vst_external_url: str, stream_id: str, timestamp: str) -> str: """Build an external screenshot URL for client-facing URLs directly, without any validation. Args: vst_external_url: External VST URL for client-facing URLs stream_id: The stream ID timestamp: The timestamp for the screenshot Returns: External screenshot URL string """ vst_external_url = vst_external_url.rstrip("/") return f"{vst_external_url}/vst/api/v1/replay/stream/{stream_id}/picture?startTime={timestamp}" class VSTSnapshotConfig(FunctionBaseConfig, name="vst.snapshot"): """Configuration for the VST Snapshot tool.""" vst_internal_url: str = Field( ..., description="The internal VST URL for making API requests (e.g., http://${INTERNAL_IP}:30888)", ) vst_external_url: str = Field( ..., description="The external VST URL for client-facing URLs (e.g., http://${EXTERNAL_IP}:30888)", ) overlay_config: bool = Field( False, description="Whether to enable overlay configuration for object detection bounding box overlays", ) time_format: Literal["offset", "iso"] = Field( "offset", description="Timestamp input format: 'iso' for ISO 8601 UTC strings (e.g. '2025-08-25T03:05:55Z'), " "'offset' for seconds since stream start. " "Must match across video_understanding, vst.video_clip, vst.snapshot, and critic_agent configs.", ) class VSTSnapshotOffsetInput(BaseModel): """Input for the VST Snapshot tool (offset mode). start_time is a float representing seconds since the beginning of the stream. """ sensor_id: str = Field( ..., description="The name of the video file uploaded or the stream ID from VST", min_length=1, ) start_time: float = Field( ..., description="Seconds since the beginning of the stream (e.g., 30.0 for 30 seconds in)", ) class VSTSnapshotISOInput(BaseModel): """Input for the VST Snapshot tool (ISO timestamp mode). start_time is an ISO 8601 UTC timestamp string. """ sensor_id: str = Field( ..., description="The name of the video file uploaded or the stream ID from VST", min_length=1, ) start_time: str = Field( ..., description="ISO 8601 UTC timestamp (e.g., '2025-08-25T03:05:55.752Z')", min_length=1, ) # Union type for backward compatibility in internal APIs VSTSnapshotInput = VSTSnapshotOffsetInput | VSTSnapshotISOInput class VSTSnapshotOutput(BaseModel): """Output for the VST Snapshot tool""" image_url: str = Field( ..., description="Direct URL to access the snapshot", ) stream_id: str = Field( ..., description="The stream ID that is mapped from the sensor ID", ) async def get_snapshot_url( stream_id: str, start_time: float | str, vst_internal_url: str, overlay_enabled: bool = False, ) -> str: """Get the snapshot URL for a given stream ID. Args: stream_id: The VST stream ID. start_time: Seconds offset (float) or ISO 8601 timestamp (str). vst_internal_url: Internal VST URL. overlay_enabled: Whether to add bounding box overlay. Returns: The snapshot image URL from VST. """ if isinstance(start_time, str): # ISO 8601 timestamp - use directly timestamp_iso = start_time else: # Seconds offset - compute from timeline timeline_start, timeline_end = await get_timeline(stream_id, vst_internal_url) picture_time = datetime.fromisoformat(timeline_start) + timedelta(seconds=start_time) if picture_time < datetime.fromisoformat(timeline_start) or picture_time > datetime.fromisoformat(timeline_end): raise ValueError(f"Picture time is out of the video timeline {timeline_start} to {timeline_end}") timestamp_iso = picture_time.isoformat(timespec="milliseconds").replace("+00:00", "Z") query_params = urllib.parse.urlencode({"startTime": timestamp_iso}) url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/replay/stream/{stream_id}/picture/url?{query_params}" # Add overlay configuration for bounding boxes overlay_param = build_overlay_config(overlay_enabled) if overlay_param: url += f"&overlay={overlay_param}" async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: async for attempt in create_retry_strategy(retries=3): with attempt: async with session.get(url) as response: if response.status != 200: raise VSTError(f"Failed to get snapshot URL: HTTP {response.status}") text = await response.text() image_url = json.loads(text).get("imageUrl") if not image_url: raise VSTError("Failed to get snapshot URL: no imageUrl in response") return str(image_url) @register_function(config_type=VSTSnapshotConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def vst_snapshot(config: VSTSnapshotConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: async def _vst_snapshot(vst_snapshot_input: VSTSnapshotOffsetInput | VSTSnapshotISOInput) -> VSTSnapshotOutput: """Get a temporary VST picture URL for `sensor_id` at `start_time`. Returns: VSTSnapshotOutput containing image URL and stream ID """ stream_id = await get_stream_id(vst_snapshot_input.sensor_id, config.vst_internal_url) image_url = await get_snapshot_url( stream_id, vst_snapshot_input.start_time, config.vst_internal_url, overlay_enabled=config.overlay_config, ) # Replace internal URL with external URL for client access image_url = f"{config.vst_external_url}{urllib.parse.urlparse(image_url).path}" return VSTSnapshotOutput(image_url=image_url, stream_id=stream_id) # Register the tool with the appropriate input schema based on time_format: # - "iso": accepts ISO 8601 UTC timestamp strings (e.g. "2025-08-25T03:05:55Z"). # Use for RTSP live streams where events have real-world wall-clock times. # - "offset": accepts floats representing seconds since start of stream (e.g. 30.0). # Use for uploaded video files where only relative position matters. # This must match the time_format of any tool calling this one (e.g. video_understanding). # # NAT's _convert_input checks `input_type == input_schema` to decide whether to pass # the full Pydantic model or extract its first field. A Union annotation would mismatch. if config.time_format == "iso": async def _vst_snapshot_iso(vst_snapshot_input: VSTSnapshotISOInput) -> VSTSnapshotOutput: return await _vst_snapshot(vst_snapshot_input) input_desc = """ \n\nInput: - sensor_id: Required. The name of the sensor or video file. - start_time: Required. ISO 8601 UTC timestamp (e.g., '2025-08-25T03:05:55.752Z'). """ func_desc = _vst_snapshot.__doc__ or "" yield FunctionInfo.create( single_fn=_vst_snapshot_iso, description=func_desc + input_desc, input_schema=VSTSnapshotISOInput, single_output_schema=VSTSnapshotOutput, ) else: async def _vst_snapshot_offset(vst_snapshot_input: VSTSnapshotOffsetInput) -> VSTSnapshotOutput: return await _vst_snapshot(vst_snapshot_input) input_desc = """ \n\nInput: - sensor_id: Required. The name of the sensor or video file. - start_time: Required. Seconds since the beginning of the stream (e.g., 30.0 for 30 seconds from the start of the video). """ func_desc = _vst_snapshot.__doc__ or "" yield FunctionInfo.create( single_fn=_vst_snapshot_offset, description=func_desc + input_desc, input_schema=VSTSnapshotOffsetInput, single_output_schema=VSTSnapshotOutput, ) ================================================ FILE: agent/src/vss_agents/tools/vst/timeline.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import json import logging import os import aiohttp from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.tools.vst.utils import VSTError from vss_agents.tools.vst.utils import get_stream_id from vss_agents.utils.retry import create_retry_strategy from vss_agents.utils.time_convert import iso8601_to_datetime logger = logging.getLogger(__name__) class VSTTimelineConfig(FunctionBaseConfig, name="vst.timeline"): """Configuration for the VST Timeline tool.""" vst_internal_url: str = Field( ..., description="The internal VST URL for API calls (e.g., http://${INTERNAL_IP}:30888)", ) class VSTTimelineInput(BaseModel): """Input for the VST Timeline tool""" sensor_id: str = Field( ..., description="The name of the sensor/video (e.g., 'warehouse_01') OR the stream ID", ) class VSTTimelineOutput(BaseModel): """Output for the VST Timeline tool""" start_timestamp: str = Field( ..., description="The start timestamp of the video", ) end_timestamp: str = Field( ..., description="The end timestamp of the video", ) async def get_timeline(stream_id: str, vst_internal_url: str | None = None) -> tuple[str, str]: """ Get the start and end timestamps for a video from VST API. This function: 1. Calls VST streams API to find the stream ID for the given sensor name 2. Calls VST timelines API to get the timeline information 3. Extracts and returns the endTime converted to ISO format Args: stream_id: The stream ID of the sensor/video, note it also works with sensor name(sensor id), internally it will be converted to stream id. vst_internal_url: Internal VST URL for API calls (defaults to VST_INTERNAL_URL env var or http://localhost:30888) Returns: ISO timestamp string (e.g., "2025-01-01T00:10:28.000Z") Raises: RuntimeError: If the video is not found or API calls fail """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") # Remove /vst suffix if present if vst_internal_url.endswith("/vst"): vst_internal_url = vst_internal_url[:-4] timelines_url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/storage/timelines" async with aiohttp.ClientSession() as session: async for retry in create_retry_strategy(retries=3, exceptions=(Exception,)): with retry: try: async with session.get(timelines_url) as response: if response.status != 200: raise RuntimeError(f"VST timelines API returned status {response.status}") text = await response.text() timelines_data = json.loads(text) timeline_list = timelines_data.get(stream_id, []) if not timeline_list: logger.info("probabaly input is sensor id or video name, trying to get stream id") stream_id = await get_stream_id(stream_id, vst_internal_url) timeline_list = timelines_data.get(stream_id, []) if not timeline_list: raise VSTError(f"No timeline found for stream {stream_id}") logger.info("Timeline for stream %s: %s", stream_id, timeline_list) start, end = timeline_list[0].get("startTime"), timeline_list[0].get("endTime") # check duration if too short, throw error start_dt = iso8601_to_datetime(start) end_dt = iso8601_to_datetime(end) duration = end_dt - start_dt if duration.total_seconds() < 1: raise VSTError(f"Timeline duration is too short for stream {stream_id}") return start, end except Exception as e: raise VSTError(f"Error getting timeline for stream {stream_id}: {e}") from e return "", "" # unreachable, but satisfies mypy @register_function(config_type=VSTTimelineConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def vst_timeline(config: VSTTimelineConfig, _: Builder) -> AsyncGenerator[FunctionInfo]: async def _vst_timeline(vst_timeline_input: VSTTimelineInput) -> VSTTimelineOutput: """Get the start and end timestamps for a video from VST.""" stream_id = await get_stream_id(vst_timeline_input.sensor_id, config.vst_internal_url) start_timestamp, end_timestamp = await get_timeline(stream_id, config.vst_internal_url) return VSTTimelineOutput(start_timestamp=start_timestamp, end_timestamp=end_timestamp) yield FunctionInfo.create( single_fn=_vst_timeline, description=_vst_timeline.__doc__, input_schema=VSTTimelineInput, single_output_schema=VSTTimelineOutput, ) ================================================ FILE: agent/src/vss_agents/tools/vst/utils.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import json import logging import os import urllib.parse from urllib.parse import urlparse from urllib.parse import urlunparse import aiohttp from vss_agents.utils.retry import create_retry_strategy logger = logging.getLogger(__name__) def build_vst_url(base_url: str, url: str) -> str: """Replace the scheme and host of *url* with those from *base_url*. This is useful when the URL returned by a service uses an external or proxy hostname but you need to reach the same resource via an internal base URL. Args: base_url: Absolute base URL (e.g. ``http://10.0.1.1:30888``). url: Full URL whose path/query/fragment should be preserved (e.g. ``http://232.2.2.34:22324/vst/api/v1/storage/file.mp4``). Returns: The *url* with its scheme and netloc replaced by those of *base_url*. """ base_parsed = urlparse(base_url.rstrip("/")) url_parsed = urlparse(url) return urlunparse( url_parsed._replace( scheme=base_parsed.scheme, netloc=base_parsed.netloc, ) ) def build_overlay_config( overlay_enabled: bool, object_ids: list[str] | None = None, ) -> str | None: """Build the overlay configuration query parameter for VST API requests. This is a shared helper used by both snapshot and video_clip tools to support bounding box overlays on VST media. Args: overlay_enabled: Whether overlay configuration is enabled. object_ids: Optional list of object IDs to display as overlays. If empty or None and overlay is enabled, all bounding boxes are shown. Returns: URL-encoded overlay configuration string, or None if overlay is disabled. """ if not overlay_enabled: return None overlay_object_ids = object_ids or [] config_dict = { "overlay": { "bbox": {"showAll": not overlay_object_ids, "objectId": overlay_object_ids}, "color": "green", "thickness": 5, "debug": True, "opacity": 254, }, } return urllib.parse.quote(json.dumps(config_dict)) class VSTError(Exception): """Base exception for VST errors.""" pass async def get_name_to_stream_id_map(vst_internal_url: str | None = None) -> dict[str, str]: """Fetch `/api/v1/sensor/streams` and return `{name: streamId}`.""" if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/sensor/streams" async with aiohttp.ClientSession() as session: async for retry in create_retry_strategy(retries=3, exceptions=(Exception,)): with retry: try: async with session.get(url) as response: if response.status != 200: raise RuntimeError(f"VST streams API returned status {response.status}") text = await response.text() payload = json.loads(text) mapping: dict[str, str] = {} for file in payload: stream_id = next(iter(file)) if isinstance(file[stream_id], list) and len(file[stream_id]) > 0: name = file[stream_id][0]["name"] mapping[name] = stream_id else: logger.warning(f"Stream ID {stream_id} is empty, skipping") return mapping except Exception as e: logger.error(f"Error getting name to stream ID map: {e}") raise e return {} # unreachable, but satisfies mypy async def get_stream_id(sensor_id: str, vst_internal_url: str | None = None) -> str: """Get the stream ID for a given sensor ID. Note: sensor_id can be the name of the sensor or the stream ID. """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") stream_id_map = await get_name_to_stream_id_map(vst_internal_url) stream_id = stream_id_map.get(sensor_id) if not stream_id: if sensor_id in stream_id_map.values(): stream_id = sensor_id else: raise VSTError( f"streamId not found for '{sensor_id}'. Available: {sorted(stream_id_map.keys())}" if stream_id_map else "streamId not found" ) return stream_id async def get_sensor_id_from_stream_id(stream_id: str, vst_internal_url: str | None = None) -> str: """Get the sensor ID (camera name) for a given stream ID (UUID). This is the reverse mapping of get_stream_id - takes a stream_id (UUID) and returns the sensor name (e.g., "Camera_03"). Args: stream_id: The stream ID (UUID) to look up vst_internal_url: Optional VST internal URL, defaults to VST_INTERNAL_URL env var Returns: The sensor ID (camera name) corresponding to the stream_id Raises: VSTError: If the stream_id is not found in VST """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") name_to_stream_id_map = await get_name_to_stream_id_map(vst_internal_url) # Reverse the mapping: {name: streamId} -> {streamId: name} stream_id_to_name_map = {stream_id_val: name for name, stream_id_val in name_to_stream_id_map.items()} sensor_id = stream_id_to_name_map.get(stream_id) if not sensor_id: # Check if stream_id is already a sensor name (not a UUID) if stream_id in name_to_stream_id_map: sensor_id = stream_id else: raise VSTError( f"sensorId not found for stream_id '{stream_id}'. Available stream_ids: {sorted(stream_id_to_name_map.keys())[:10]}..." if stream_id_to_name_map else "sensorId not found" ) return sensor_id async def validate_video_url(url: str, timeout: int = 30) -> bool: """ Validate if a video URL is accessible and returns a valid response. First tries HEAD request, then falls back to GET with range header if HEAD fails. Args: url: The video URL to validate timeout: Timeout in seconds for the request (default: 30) """ try: logger.info(f"Validating video URL: {url}") async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session: # First try HEAD request try: async with session.head(url) as response: is_valid = 200 <= response.status < 300 if is_valid: content_type = response.headers.get("content-type", "").lower() content_length = response.headers.get("content-length", "0") logger.info( f"URL validation successful (HEAD) - Status: {response.status}, Content-Type: {content_type}, Content-Length: {content_length}" ) # Additional check for video content type (optional) if content_type and not any( video_type in content_type for video_type in ["video/", "application/octet-stream"] ): logger.warning(f"URL may not contain video content. Content-Type: {content_type}") # Check if content length is reasonable (not empty) if content_length == "0": logger.warning("URL returned zero content length") return True else: logger.warning( f"HEAD request failed with status {response.status}, trying GET with range header" ) except Exception as e: logger.warning(f"HEAD request failed: {e}, trying GET with range header") # Fallback to GET request with range header (only first few bytes) try: headers = {"Range": "bytes=0-1023"} # Only request first 1KB async with session.get(url, headers=headers) as response: is_valid = 200 <= response.status < 300 or response.status == 206 # 206 = Partial Content if is_valid: content_type = response.headers.get("content-type", "").lower() content_length = response.headers.get("content-length", "0") logger.info( f"URL validation successful (GET with range) - Status: {response.status}, Content-Type: {content_type}, Content-Length: {content_length}" ) # Additional check for video content type (optional) if content_type and not any( video_type in content_type for video_type in ["video/", "application/octet-stream"] ): logger.warning(f"URL may not contain video content. Content-Type: {content_type}") return True else: raise VSTError(f"URL validation failed - HTTP Status: {response.status}") except Exception as e: raise VSTError(f"GET request with range also failed: {e}") from e except aiohttp.ClientError as e: raise VSTError(f"Client error validating URL {url}: {e}") from e except Exception as e: raise VSTError(f"Unexpected error validating URL {url}: {e}") from e async def delete_vst_sensor(vst_url: str, sensor_id: str) -> tuple[bool, str]: """ Delete a sensor registration from VST. This removes the sensor metadata (name, URL, etc.) but not the stored video files. Must be paired with delete_vst_storage to fully remove a video. Args: vst_url: Base VST URL (e.g., http://localhost:30888) sensor_id: The sensor UUID to delete Returns: (success, message) tuple """ url = f"{vst_url.rstrip('/')}/vst/api/v1/sensor/{sensor_id}" logger.info("Deleting VST sensor: DELETE %s", url) try: async with ( aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session, session.delete(url) as response, ): if response.status in (200, 204): logger.info("VST sensor deleted: %s", sensor_id) return True, "OK" text = await response.text() return False, f"VST returned {response.status}: {text}" except Exception as e: logger.error("VST sensor delete failed: %s", e, exc_info=True) return False, str(e) async def delete_vst_storage(vst_url: str, sensor_id: str) -> tuple[bool, str]: """ Delete stored video files from VST. VST requires a time range for deletion. This function fetches the timeline for the sensor, computes the full start/end range, then issues the delete. Args: vst_url: Base VST URL (e.g., http://localhost:30888) sensor_id: The sensor UUID whose storage to delete Returns: (success, message) tuple """ vst_url = vst_url.rstrip("/") timeline_url = f"{vst_url}/vst/api/v1/storage/timelines" logger.info("Getting VST timeline for storage delete: GET %s", timeline_url) try: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session: async with session.get(timeline_url) as response: if response.status != 200: text = await response.text() return False, f"Failed to get timeline: {response.status}: {text}" text = await response.text() timelines = json.loads(text) stream_timeline = timelines.get(sensor_id) if not stream_timeline or len(stream_timeline) == 0: logger.info("No timeline found for %s, nothing to delete", sensor_id) return True, "No storage to delete" start_times = [t.get("startTime") for t in stream_timeline if t.get("startTime")] end_times = [t.get("endTime") for t in stream_timeline if t.get("endTime")] if not start_times or not end_times: return True, "No storage to delete" start_time = min(start_times) end_time = max(end_times) storage_url = f"{vst_url}/vst/api/v1/storage/file/{sensor_id}" params = {"startTime": start_time, "endTime": end_time} logger.info("Deleting VST storage: DELETE %s params=%s", storage_url, params) async with ( aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session, session.delete(storage_url, params=params) as del_response, ): if del_response.status in (200, 204): logger.info("VST storage deleted: %s", sensor_id) return True, "OK" text = await del_response.text() return False, f"VST storage returned {del_response.status}: {text}" except Exception as e: logger.error("VST storage delete failed: %s", e, exc_info=True) return False, str(e) class VSTDirectUploader: """Handles direct VST API uploads for media files.""" def __init__(self, vst_api_url: str): """ Initialize VST direct uploader. Args: vst_api_url: Base URL for VST API """ self.vst_api_url = vst_api_url.rstrip("/") async def upload_media_file( self, media_file_path: str, timestamp: str | None = None, sensor_id: str | None = None, stream_id: str | None = None, event_info: str | None = None, stream_name: str | None = None, tag: str | None = None, ) -> bool: """ Upload media file to VST API with optional parameters. Args: media_file_path: Path to the media file to upload timestamp: ISO format timestamp (optional) sensor_id: Sensor ID for the upload (optional) stream_id: Stream ID for the upload (optional) event_info: Description of the event (optional) stream_name: Stream name for the upload (optional) tag: Tag for categorization (optional) Returns: True if upload successful, False otherwise """ try: # Check if media file exists if not os.path.exists(media_file_path): logger.error(f"Media file not found: {media_file_path}") return False upload_url = f"{self.vst_api_url}/vst/api/v1/storage/file" metadata = {} if timestamp is not None: metadata["timestamp"] = timestamp if sensor_id: metadata["sensorId"] = sensor_id if stream_id: metadata["streamId"] = stream_id if event_info: metadata["eventInfo"] = event_info if stream_name: metadata["streamName"] = stream_name if tag: metadata["tag"] = tag logger.info(f"Uploading {media_file_path}") logger.debug(f"Metadata: {metadata}") # Make the upload request with file context manager with open(media_file_path, "rb") as media_file: # Build multipart form data for aiohttp form_data = aiohttp.FormData() form_data.add_field("metadata", json.dumps(metadata)) form_data.add_field( "mediaFile", media_file, filename=os.path.basename(media_file_path), content_type="video/mp4", ) async with ( aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=300)) as session, session.post(upload_url, data=form_data) as response, ): if response.status == 200: logger.info(f"Successfully uploaded {media_file_path}") # Handle both JSON and text responses content_type = response.headers.get("Content-Type", "") if "application/json" in content_type: logger.info(f"Response: {await response.json()}") else: logger.info(f"Response: {await response.text()}") return True else: logger.error(f"Upload failed with status {response.status}: {await response.text()}") return False except Exception as e: logger.error(f"Error uploading media file: {e}") return False async def get_streams_info(vst_internal_url: str | None = None) -> dict[str, dict[str, str]]: """ Fetch `/api/v1/sensor/streams` and return full stream info including URLs. Returns: {stream_id: {"name": name, "url": rtsp_url}} Note: this only validates 200 status code, the url is not validated. """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/sensor/streams" async with aiohttp.ClientSession() as session: async for retry in create_retry_strategy(retries=3, exceptions=(Exception,)): with retry: try: async with session.get(url) as response: if response.status != 200: raise VSTError(f"VST streams API returned status {response.status}") text = await response.text() payload = json.loads(text) result: dict[str, dict[str, str]] = {} for entry in payload: stream_id = next(iter(entry)) stream_list = entry[stream_id] if stream_list and len(stream_list) > 0: result[stream_id] = { "name": stream_list[0].get("name", ""), "url": stream_list[0].get("url", ""), } return result except Exception as e: logger.error(f"Error getting streams info: {e}") raise e return {} # unreachable, but satisfies mypy async def get_stream_info_by_name(name: str, vst_internal_url: str | None = None) -> tuple[str | None, str | None]: """ Find stream_id and RTSP URL by sensor/camera name. Returns: (stream_id, rtsp_url) or (None, None) if not found """ streams_info = await get_streams_info(vst_internal_url) for stream_id, info in streams_info.items(): if info.get("name") == name: return stream_id, info.get("url") return None, None async def add_sensor( sensor_url: str, name: str, username: str = "", password: str = "", location: str = "", tags: str = "", vst_internal_url: str | None = None, ) -> tuple[bool, str, str | None]: """ Add a new sensor to VST. Returns: (success, message, sensor_id) """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/sensor/add" payload: dict[str, str] = { "sensorUrl": sensor_url, "name": name, } if username: payload["username"] = username if password: payload["password"] = password if location: payload["location"] = location if tags: payload["tags"] = tags logger.info(f"Adding sensor to VST: POST {url}") async with aiohttp.ClientSession() as session: try: async with session.post(url, json=payload) as response: if response.status not in (200, 201): # Try to parse VST error response for cleaner message try: error_json = await response.json(content_type=None) error_msg = error_json.get("error_message", str(error_json)) except Exception: error_msg = await response.text() error = f"VST error: {error_msg}" logger.error(f"VST returned {response.status}: {error_msg}") return False, error, None # Use content_type=None to handle text/plain responses from VST result = await response.json(content_type=None) sensor_id = result.get("sensorId") or result.get("id") if not sensor_id: error = f"VST response missing sensor ID: {result}" logger.error(error) return False, error, None logger.info(f"VST sensor created: {sensor_id}") return True, "OK", sensor_id except Exception as e: error = f"VST add sensor request failed: {e!s}" logger.error(error, exc_info=True) return False, error, None async def delete_sensor(sensor_id: str | None, vst_internal_url: str | None = None) -> tuple[bool, str]: """ Delete a sensor from VST. Returns: (success, message) """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/sensor/{sensor_id}" logger.info(f"Deleting VST sensor: DELETE {url}") async with aiohttp.ClientSession() as session: try: async with session.delete(url) as response: if response.status in (200, 204): logger.info(f"VST sensor deleted: {sensor_id}") return True, "OK" # Try to parse VST error response for cleaner message try: error_json = await response.json(content_type=None) error_msg = error_json.get("error_message", str(error_json)) except Exception: error_msg = await response.text() return False, f"VST error: {error_msg}" except Exception as e: return False, str(e) async def get_storage_timeline( sensor_id: str | None, vst_internal_url: str | None = None ) -> tuple[bool, str, str | None, str | None]: """ Get storage timeline (start_time, end_time) for a sensor. Returns: (success, message, start_time, end_time) """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/storage/timelines" logger.info(f"Getting VST timeline: GET {url}") try: async with aiohttp.ClientSession() as session, session.get(url) as response: if response.status != 200: # Try to parse VST error response for cleaner message try: error_json = await response.json(content_type=None) error_msg = error_json.get("error_message", str(error_json)) except Exception: error_msg = await response.text() return False, f"VST error: {error_msg}", None, None # Use content_type=None to handle text/plain responses from VST timelines = await response.json(content_type=None) stream_timeline = timelines.get(sensor_id) if not stream_timeline or len(stream_timeline) == 0: logger.info(f"No timeline found for {sensor_id}") return True, "No timeline", None, None start_time = stream_timeline[0].get("startTime") end_time = stream_timeline[0].get("endTime") return True, "OK", start_time, end_time except Exception as e: return False, str(e), None, None async def delete_storage(sensor_id: str | None, vst_internal_url: str | None = None) -> tuple[bool, str]: """ Delete storage files for a sensor from VST. Returns: (success, message) """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") # Get timeline first success, msg, start_time, end_time = await get_storage_timeline(sensor_id, vst_internal_url) if not success: return False, msg if start_time is None or end_time is None: logger.info(f"No timeline found for {sensor_id}, nothing to delete") return True, "No storage to delete" # Delete storage url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/storage/file/{sensor_id}" params = {"startTime": start_time, "endTime": end_time} logger.info(f"Deleting VST storage: DELETE {url} params={params}") try: async with aiohttp.ClientSession() as session, session.delete(url, params=params) as response: if response.status in (200, 204): logger.info(f"VST storage deleted: {sensor_id}") return True, "OK" # Try to parse VST error response for cleaner message try: error_json = await response.json(content_type=None) error_msg = error_json.get("error_message", str(error_json)) except Exception: error_msg = await response.text() return False, f"VST error: {error_msg}" except Exception as e: return False, str(e) async def get_rtsp_url(sensor_id: str, vst_internal_url: str | None = None) -> tuple[bool, str, str | None]: """ Get RTSP URL for a sensor from VST streams API. Returns: (success, message, rtsp_url) """ async for retry in create_retry_strategy(delay=0.1, retries=25, exceptions=(Exception,)): with retry: streams_info = await get_streams_info(vst_internal_url) if sensor_id in streams_info: rtsp_url = streams_info[sensor_id].get("url") if isinstance(rtsp_url, str) and rtsp_url.startswith("rtsp://"): return True, "OK", rtsp_url else: logger.warning(f"RTSP URL is not valid: {rtsp_url}, retrying...") raise ValueError(f"RTSP URL is not valid: {rtsp_url}") else: logger.warning(f"Sensor ID {sensor_id} not found in streams info, retrying...") raise ValueError(f"Sensor ID {sensor_id} not found in streams info") return False, f"RTSP URL not found for sensor {sensor_id}", None ================================================ FILE: agent/src/vss_agents/tools/vst/video_clip.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """VST Video Clip tool - video URL tool with bounding box overlay support. Supports two timestamp formats controlled by config: - 'offset' format: start_time/end_time are floats (seconds since beginning of stream) - 'iso' format: start_time/end_time are ISO 8601 UTC timestamp strings """ import asyncio from collections.abc import AsyncGenerator import datetime import json import logging import os from typing import Literal import urllib.parse import aiohttp from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from pydantic import model_validator from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import VSTError from vss_agents.tools.vst.utils import build_overlay_config from vss_agents.tools.vst.utils import get_stream_id from vss_agents.tools.vst.utils import validate_video_url from vss_agents.utils.retry import create_retry_strategy logger = logging.getLogger(__name__) class VSTVideoClipConfig(FunctionBaseConfig, name="vst.video_clip"): """Configuration for the VST Video Clip tool.""" vst_internal_url: str = Field( ..., description="The internal VST URL for making API requests (e.g., http://${INTERNAL_IP}:30888)", ) vst_external_url: str = Field( ..., description="The external VST URL for client-facing URLs (e.g., http://${EXTERNAL_IP}:30888)", ) overlay_config: bool = Field( False, description="Whether to enable overlay configuration for object detection bounding box overlays", ) time_format: Literal["offset", "iso"] = Field( "offset", description="Timestamp input format: 'iso' for ISO 8601 UTC strings (e.g. '2025-08-25T03:05:55Z'), " "'offset' for seconds since stream start. " "Must match across video_understanding, vst.video_clip, vst.snapshot, and critic_agent configs.", ) class VSTVideoClipOffsetInput(BaseModel): """Input for the VST Video Clip tool (offset mode). start_time and end_time are floats representing seconds since the beginning of the stream. """ sensor_id: str = Field( ..., description="The name or the stream ID of the video file uploaded", min_length=1, ) start_time: float | None = Field( None, description="Start time in seconds since the beginning of the stream, or None for entire video", ) end_time: float | None = Field( None, description="End time in seconds since the beginning of the stream, or None for entire video", ) object_ids: list[str] | None = Field( None, description="Optional list of object IDs to display as overlays in the video", ) @model_validator(mode="before") @classmethod def validate_start_and_end_time(cls, info: dict) -> dict: start = info.get("start_time") end = info.get("end_time") if start is not None: start = float(start) if start < 0: raise ValueError("Start time must be non-negative") info["start_time"] = start if end is not None: end = float(end) if end < 0: raise ValueError("End time must be non-negative") info["end_time"] = end if start is not None and end is not None and start >= end: raise ValueError("Start time must be before end time") return info class VSTVideoClipISOInput(BaseModel): """Input for the VST Video Clip tool (ISO timestamp mode). start_time and end_time are ISO 8601 UTC timestamp strings. """ sensor_id: str = Field( ..., description="The name or the stream ID of the video file uploaded", min_length=1, ) start_time: str | None = Field( None, description="Start time as ISO 8601 UTC timestamp (e.g., '2025-08-25T03:05:55.752Z'), or None for entire video", ) end_time: str | None = Field( None, description="End time as ISO 8601 UTC timestamp (e.g., '2025-08-25T03:06:15.752Z'), or None for entire video", ) object_ids: list[str] | None = Field( None, description="Optional list of object IDs to display as overlays in the video", ) # Union type for backward compatibility in internal APIs VSTVideoClipInput = VSTVideoClipOffsetInput | VSTVideoClipISOInput class VSTVideoClipOutput(BaseModel): """Output for the VST Video Clip tool""" video_url: str = Field( ..., description="Direct URL to access the video file", ) stream_id: str = Field( ..., description="The stream ID that is mapped from the sensor ID", ) async def get_video_url( stream_id: str, start_time: float | str | None = None, end_time: float | str | None = None, vst_internal_url: str | None = None, overlay_enabled: bool = False, object_ids: list[str] | None = None, ) -> str: """Get the video URL for a given stream ID. Args: stream_id: The VST stream ID. start_time: Seconds offset (float), ISO 8601 timestamp (str), or None for full video. end_time: Seconds offset (float), ISO 8601 timestamp (str), or None for full video. vst_internal_url: Internal VST URL. overlay_enabled: Whether to add bounding box overlay configuration. object_ids: Optional list of object IDs for overlay filtering. Returns: The video URL from VST. """ if vst_internal_url is None: vst_internal_url = os.getenv("VST_INTERNAL_URL", "http://localhost:30888") # Determine if we're using ISO timestamps or seconds offsets if isinstance(start_time, str) and isinstance(end_time, str): # ISO timestamps - use directly start_time_iso = start_time end_time_iso = end_time else: # Seconds offsets - compute from timeline start_timestamp, end_timestamp = await get_timeline(stream_id, vst_internal_url) # Normalize to timezone-aware UTC datetimes start_dt = datetime.datetime.fromisoformat(start_timestamp.replace("Z", "+00:00")) end_dt = datetime.datetime.fromisoformat(end_timestamp.replace("Z", "+00:00")) start_time_pts = start_dt.timestamp() * 1000 end_time_pts = end_dt.timestamp() * 1000 if start_time is not None and not isinstance(start_time, str): clip_start_time_pts = min(start_time * 1000 + start_time_pts, end_time_pts) else: clip_start_time_pts = start_time_pts if end_time is not None and not isinstance(end_time, str): clip_end_time_pts = min(end_time * 1000 + start_time_pts, end_time_pts) else: clip_end_time_pts = end_time_pts # Strengthened validation if ( clip_start_time_pts < start_time_pts or clip_end_time_pts > end_time_pts or clip_end_time_pts < clip_start_time_pts ): raise ValueError( f"Clip times must be within the stream timeline {start_timestamp}..{end_timestamp} and start <= end, got {clip_start_time_pts}..{clip_end_time_pts}" ) start_time_iso = ( datetime.datetime.fromtimestamp(clip_start_time_pts / 1000, tz=datetime.UTC) .isoformat(timespec="milliseconds") .replace("+00:00", "Z") ) end_time_iso = ( datetime.datetime.fromtimestamp(clip_end_time_pts / 1000, tz=datetime.UTC) .isoformat(timespec="milliseconds") .replace("+00:00", "Z") ) # Build the VST API URL query_params = urllib.parse.urlencode( { "startTime": start_time_iso, "endTime": end_time_iso, "blocking": "true", "disableAudio": "true", } ) url = f"{vst_internal_url.rstrip('/')}/vst/api/v1/storage/file/{stream_id}/url?{query_params}" # Add overlay configuration for bounding boxes overlay_param = build_overlay_config(overlay_enabled, object_ids) if overlay_param: url += f"&configuration={overlay_param}" async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async for retry in create_retry_strategy(retries=3, exceptions=(aiohttp.ClientError, asyncio.TimeoutError)): with retry: async with session.get(url) as response: if response.status != 200: raise VSTError(f"Failed to get video clip URL: HTTP {response.status}") text = await response.text() try: result = json.loads(text) except json.JSONDecodeError as e: raise VSTError(f"Invalid JSON in VST response: {e}") from e video_clip_url = result.get("videoUrl") if not video_clip_url: raise VSTError("No videoUrl in response") return str(video_clip_url) @register_function(config_type=VSTVideoClipConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def vst_video_clip(config: VSTVideoClipConfig, _: Builder) -> AsyncGenerator[FunctionInfo]: async def _vst_video_clip( vst_video_clip_input: VSTVideoClipOffsetInput | VSTVideoClipISOInput, ) -> VSTVideoClipOutput: """Get a temporary VST video URL for `video_name` over an optional time range. Args: Note: start_time MUST be smaller than end_time Returns: VSTVideoClipOutput containing video URL and stream ID """ stream_id = await get_stream_id(vst_video_clip_input.sensor_id, config.vst_internal_url) video_clip_url = await get_video_url( stream_id, vst_video_clip_input.start_time, vst_video_clip_input.end_time, config.vst_internal_url, overlay_enabled=config.overlay_config, object_ids=vst_video_clip_input.object_ids, ) await validate_video_url(video_clip_url) # Replace internal URL with external URL for client access video_clip_url = f"{config.vst_external_url}{urllib.parse.urlparse(video_clip_url).path}" return VSTVideoClipOutput(video_url=video_clip_url, stream_id=stream_id) # Register the tool with the appropriate input schema based on time_format: # - "iso": accepts ISO 8601 UTC timestamp strings (e.g. "2025-08-25T03:05:55Z"). # Use for RTSP live streams where events have real-world wall-clock times. # - "offset": accepts floats representing seconds since start of stream (e.g. 30.0). # Use for uploaded video files where only relative position matters. # This must match the time_format of any tool calling this one (e.g. video_understanding, critic_agent). # # NAT's _convert_input checks `input_type == input_schema` to decide whether to pass # the full Pydantic model or extract its first field. A Union annotation would mismatch. if config.time_format == "iso": async def _vst_video_clip_iso(vst_video_clip_input: VSTVideoClipISOInput) -> VSTVideoClipOutput: return await _vst_video_clip(vst_video_clip_input) input_desc = """ \n\nInput: - sensor_id: Required. The name of the sensor or video file. - start_time: Optional. ISO 8601 UTC timestamp (e.g., '2025-08-25T03:05:55.752Z'), if not provided, the entire video will be returned. - end_time: Optional. ISO 8601 UTC timestamp (e.g., '2025-08-25T03:06:15.752Z'), if not provided, the entire video will be returned. """ func_desc = vst_video_clip.__doc__ or "" yield FunctionInfo.create( single_fn=_vst_video_clip_iso, description=func_desc + input_desc, input_schema=VSTVideoClipISOInput, single_output_schema=VSTVideoClipOutput, ) else: async def _vst_video_clip_offset(vst_video_clip_input: VSTVideoClipOffsetInput) -> VSTVideoClipOutput: return await _vst_video_clip(vst_video_clip_input) input_desc = """ \n\nInput: - sensor_id: Required. The name of the sensor or video file. - start_time: Optional. Seconds since the beginning of the stream, if not provided, the entire video will be returned. - end_time: Optional. Seconds since the beginning of the stream, if not provided, the entire video will be returned. """ func_desc = _vst_video_clip.__doc__ or "" yield FunctionInfo.create( single_fn=_vst_video_clip_offset, description=func_desc + input_desc, input_schema=VSTVideoClipOffsetInput, single_output_schema=VSTVideoClipOutput, ) ================================================ FILE: agent/src/vss_agents/tools/vst/video_list.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import datetime import logging from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import get_name_to_stream_id_map logger = logging.getLogger(__name__) class VSTVideoListConfig(FunctionBaseConfig, name="vst.video_list"): """Configuration for the VST Video List tool.""" vst_internal_url: str = Field( ..., description="The internal VST URL for API calls (e.g., http://${INTERNAL_IP}:30888)", ) class VSTVideoListInput(BaseModel): """Input for the VST Video List tool""" pass class VSTVideoListOutput(BaseModel): """Output for the VST Video List tool.""" video_list: list[dict[str, str | float]] = Field( ..., description="List of available video names and their durations", ) @register_function(config_type=VSTVideoListConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def _vst_video_list(config: VSTVideoListConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: async def _vst_video_list(vst_video_list_input: VSTVideoListInput) -> VSTVideoListOutput: # noqa: ARG001 """Get the list of available video names from VST.""" name_to_stream_id = await get_name_to_stream_id_map(config.vst_internal_url) output: list[dict[str, str | float]] = [] for name, stream_id in name_to_stream_id.items(): start_timestamp, end_timestamp = await get_timeline(stream_id, config.vst_internal_url) duration = ( datetime.datetime.fromisoformat(end_timestamp.replace("Z", "+00:00")) - datetime.datetime.fromisoformat(start_timestamp.replace("Z", "+00:00")) ).total_seconds() output.append({"name": name, "duration": duration}) return VSTVideoListOutput(video_list=output) yield FunctionInfo.create( single_fn=_vst_video_list, description=_vst_video_list.__doc__, single_output_schema=VSTVideoListOutput, input_schema=VSTVideoListInput, ) ================================================ FILE: agent/src/vss_agents/tools/vst_download.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging from pathlib import Path import httpx from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class VSTDownloadConfig(FunctionBaseConfig, name="vst_download"): """Configuration for the VST Download tool.""" vst_backend_url: str = Field(..., description="The URL of the VST backend server") download_timeout: int = Field(default=300, description="Download timeout in seconds") chunk_size: int = Field(default=8192, description="Chunk size for streaming download") class VSTDownloadInput(BaseModel): """Input for the VST Download tool""" video_id: str = Field(..., description="The VST video ID to download") filename: str = Field(..., description="The filename to save the downloaded video as") start_time: int = Field(..., description="Start time in milliseconds") end_time: int = Field(..., description="End time in milliseconds") container: str = Field(default="mp4", description="Video container format (mp4, mkv, etc.)") asset_path: str = Field(..., description="Directory path where the video will be saved") class VSTDownloadOutput(BaseModel): """Output for the VST Download tool""" local_file_path: str = Field(..., description="The local path where the video was saved") file_size_bytes: int = Field(..., description="Size of the downloaded file in bytes") duration_ms: int = Field(..., description="Duration of the downloaded clip in milliseconds") cleanup_required: bool = Field(default=True, description="Whether file needs cleanup after use") @register_function(config_type=VSTDownloadConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def vst_download(config: VSTDownloadConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Tool to download video clips from the VST backend. Downloads a specific time range from a VST video to local storage. """ async def _vst_download(vst_download_input: VSTDownloadInput) -> VSTDownloadOutput: """ Download a video clip from VST backend for the specified time range. Input: vst_download_input: VSTDownloadInput with download parameters Returns: VSTDownloadOutput: Information about the downloaded file """ # Ensure asset path exists asset_path = Path(vst_download_input.asset_path) asset_path.mkdir(parents=True, exist_ok=True) # Construct local file path local_file_path = asset_path / vst_download_input.filename try: async with httpx.AsyncClient( timeout=httpx.Timeout( connect=10.0, read=config.download_timeout, write=60.0, pool=60.0, ) ) as client: # Request video clip from VST backend download_params: dict[str, str | int] = { "id": vst_download_input.video_id, "startTime": vst_download_input.start_time, "endTime": vst_download_input.end_time, "container": vst_download_input.container, } logger.info( f"Downloading VST video clip: {vst_download_input.video_id} " f"({vst_download_input.start_time}ms-{vst_download_input.end_time}ms)" ) # Stream download from VST backend async with client.stream( "GET", f"{config.vst_backend_url}/api/v1/storage/file", params=download_params ) as response: response.raise_for_status() # Get file size from headers if available content_length = response.headers.get("content-length") expected_size = int(content_length) if content_length else None # Download file in chunks file_size = 0 with open(local_file_path, "wb") as f: async for chunk in response.aiter_bytes(chunk_size=config.chunk_size): f.write(chunk) file_size += len(chunk) # Verify download if expected_size and file_size != expected_size: logger.warning(f"Downloaded size ({file_size}) doesn't match expected size ({expected_size})") # Calculate duration of the clip duration_ms = vst_download_input.end_time - vst_download_input.start_time logger.info( f"Successfully downloaded VST video clip to: {local_file_path} " f"(size: {file_size} bytes, duration: {duration_ms}ms)" ) return VSTDownloadOutput( local_file_path=str(local_file_path), file_size_bytes=file_size, duration_ms=duration_ms ) except httpx.TimeoutException: logger.error(f"VST download timeout after {config.download_timeout} seconds") # Clean up partial file if local_file_path.exists(): local_file_path.unlink() raise RuntimeError(f"VST download timeout for video {vst_download_input.video_id}") from None except httpx.HTTPStatusError as e: # Reading response in a safe manner try: response_text = await e.response.aread() error_text = response_text.decode("utf-8", errors="ignore") except Exception: error_text = "Unable to read response content" logger.error(f"VST download HTTP error: {e.response.status_code} - {error_text}") # Clean up partial file if local_file_path.exists(): local_file_path.unlink() raise RuntimeError( f"VST download failed for video {vst_download_input.video_id}: HTTP {e.response.status_code}" ) from e except Exception as e: logger.error(f"Error downloading from VST: {e}") # Clean up partial file if local_file_path.exists(): local_file_path.unlink() raise RuntimeError(f"VST download failed for video {vst_download_input.video_id}: {e}") from e yield FunctionInfo.create( single_fn=_vst_download, description=_vst_download.__doc__, input_schema=VSTDownloadInput, single_output_schema=VSTDownloadOutput, ) ================================================ FILE: agent/src/vss_agents/tools/vst_files.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator import logging from typing import Any import httpx from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function_info import FunctionInfo from nat.cli.register_workflow import register_function from nat.data_models.function import FunctionBaseConfig from pydantic import BaseModel from pydantic import Field logger = logging.getLogger(__name__) class VSTFilesConfig(FunctionBaseConfig, name="vst_files"): """Configuration for the VST Files tool.""" vst_backend_url: str = Field(..., description="The URL of the VST backend server") timeout: int = Field(default=30, description="Request timeout in seconds") use_mock: bool = Field(True, description="Use mock data instead of real VST API for development") offset: int = Field(0, description="Start offset to fetch the records from VST API") limit: int = Field(100, description="Maximum number of records to fetch from VST API") mock_video_list: dict = Field( default={ "b7a1c1f2-9c0e-4d8d-8a6a-2e5f7d2e3c1b": [ { "mediaFilePath": "/home/vst/vst_release/streamer_videos/assault_camera_1.mp4", "metadataFilePath": "./media/events/20240115_103000.json", "metadata": { "eventInfo": "Parking lot surveillance camera", "timestamp": 1752045606222, "id": "a09612ec-f64e-404f-ac74-0ecf1175980a", # change this id as needed "streamName": "assault_camera_1", "sensorId": "b7a1c1f2-9c0e-4d8d-8a6a-2e5f7d2e3c1b", "duration": 14, # Duration in seconds }, } ] }, description="Mock VST data matching real API response format with nested sensor structure", ) class VSTFilesInput(BaseModel): """Input for the VST Files tool""" question: str = Field(..., description="The user's query to find relevant video files") @register_function(config_type=VSTFilesConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) async def vst_files(config: VSTFilesConfig, _builder: Builder) -> AsyncGenerator[FunctionInfo]: """ Query the VST backend for a list of files matching the user's query Returns a dictionary mapping VST video IDs to their metadata. """ async def _vst_files(_vst_files_input: VSTFilesInput) -> dict[str, dict[str, Any]]: """ Query the VST backend to get available video files and their metadata. Input: vst_files_input: VSTFilesInput containing the user query Returns: Dict[str, Dict[str, Any]]: Dictionary mapping VST video IDs to metadata Example: { "vst_id_123": { "filename": "assault_camera_1.mp4", "duration": 120.5, "sensor_id": "cam1", "timestamp": 1234567890, } } """ if config.use_mock: logger.info("Using mock VST data for development") vst_response = config.mock_video_list else: try: async with httpx.AsyncClient(timeout=config.timeout) as client: # Query VST backend for available videos response = await client.get( f"{config.vst_backend_url}/api/v1/storage/file/list", params={"offset": config.offset, "limit": config.limit}, ) response.raise_for_status() vst_response = response.json() logger.info(f"VST backend returned {len(vst_response)} videos") except httpx.TimeoutException: logger.error(f"VST backend timeout after {config.timeout} seconds") return {} except httpx.HTTPStatusError as e: # Reading response in a safe manner try: error_text = e.response.text except Exception: error_text = "Unable to read response content" logger.error(f"VST backend HTTP error: {e.response.status_code} - {error_text}") return {} except Exception as e: logger.error(f"Error querying VST backend: {e}") return {} # Transform VST response to expected format available_videos: list[dict[str, str]] = [] for sensor_id, clips in vst_response.items(): for clip in clips: # Extract filename from mediaFilePath and clean it up media_file_path = clip.get("mediaFilePath", "") if media_file_path: raw_filename = media_file_path.split("/")[-1] # remove extra dots from filename, keep only the last extension if "." in raw_filename: name_part = raw_filename.rsplit(".", 1)[0] ext_part = raw_filename.rsplit(".", 1)[1] # Replace any remaining dots in name with underscores clean_name = name_part.replace(".", "_") filename = f"{clean_name}.{ext_part}" else: filename = f"{raw_filename}.mp4" # Add .mp4 if no extension else: filename = "unknown.mp4" # Use metadata.id as video_id, fallback to generating one if missing metadata = clip.get("metadata", {}) video_id = metadata.get("id", f"{sensor_id}_{len(available_videos)}") available_videos.append( { "vst_id": video_id, "filename": filename, "sensor_id": sensor_id, "timestamp": metadata.get("timestamp", 0), "duration": metadata.get("duration", 0.0), } ) logger.info(f"Processed {len(available_videos)} video clips from {len(vst_response)} sensors") # Create files_ids dict with full metadata files_metadata = { f["vst_id"]: { "filename": f["filename"], "sensor_id": f["sensor_id"], "duration": f["duration"], "timestamp": f["timestamp"], } for f in available_videos } logger.info(f"!!!All files metadata: {files_metadata}") return files_metadata yield FunctionInfo.create( single_fn=_vst_files, description=_vst_files.__doc__, input_schema=VSTFilesInput, single_output_schema=dict[str, dict[str, Any]], ) ================================================ FILE: agent/src/vss_agents/utils/asyncmixin.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import Generator from typing import Any class AsyncMixin: __storedargs: tuple[tuple[Any, ...], dict[str, Any]] async_initialized: bool def __init__(self, *args: Any, **kwargs: Any) -> None: """ Standard constructor used for arguments pass Do not override. Use __ainit__ instead """ self.__storedargs = args, kwargs self.async_initialized = False async def __ainit__(self, *args: Any, **kwargs: Any) -> None: """Async constructor, you should implement this""" async def __initobj(self) -> "AsyncMixin": """Crutch used for __await__ after spawning""" assert not self.async_initialized self.async_initialized = True # pass the parameters to __ainit__ that passed to __init__ await self.__ainit__(*self.__storedargs[0], **self.__storedargs[1]) return self def __await__(self) -> Generator[Any, None, "AsyncMixin"]: return self.__initobj().__await__() ================================================ FILE: agent/src/vss_agents/utils/file_mapping.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from dataclasses import dataclass from enum import Enum import logging import os import tempfile from typing import Any logger = logging.getLogger(__name__) class StorageType(Enum): """Types of video storage backends""" VST = "vst" VSS = "vss" LOCAL = "local" @dataclass class VideoFileInfo: """Information about a video file from storage backend""" filename: str storage_type: StorageType storage_id: str # VST video_id or VSS file_id duration: float sensor_id: str | None = None timestamp: int | None = None local_path: str | None = None # Full path for LOCAL storage type class FileMapping: """ Central service for mapping filenames to storage backend IDs. Provides abstraction so tools only need to know filenames. """ def __init__(self) -> None: self._filename_to_info: dict[str, VideoFileInfo] = {} self._vss_filename_to_id: dict[str, str] = {} self._vst_filename_to_id: dict[str, str] = {} def add_vst_files(self, vst_files_data: dict[str, dict]) -> None: """ Add VST file mappings from vst_files tool response. Args: vst_files_data: Response from vst_files tool Format: { "video_id_123": { "filename": "camera1.mp4", "duration": 120.5, "sensor_id": "sensor_001", "timestamp": 1234567890 } } """ for vst_id, file_data in vst_files_data.items(): filename = file_data["filename"] video_info = VideoFileInfo( filename=filename, storage_type=StorageType.VST, storage_id=vst_id, duration=file_data.get("duration", 0.0), sensor_id=file_data.get("sensor_id"), timestamp=file_data.get("timestamp"), ) self._filename_to_info[filename] = video_info self._vst_filename_to_id[filename] = vst_id logger.info(f"Added VST mapping: {filename} -> {vst_id}") def add_vss_files(self, vss_files_data: dict[str, str]) -> None: """ Add VSS file mappings from vss_files tool response. Args: vss_files_data: Response from vss_files tool Format: {"vss_id_123": "filename.mp4", ...} """ for vss_id, filename in vss_files_data.items(): if filename not in self._filename_to_info: video_info = VideoFileInfo( filename=filename, storage_type=StorageType.VSS, storage_id=vss_id, duration=0.0, # default duration ) self._filename_to_info[filename] = video_info # VSS mapping for chat tool self._vss_filename_to_id[filename] = vss_id logger.info(f"Added VSS mapping: {filename} -> {vss_id}") def get_file_info(self, filename: str) -> VideoFileInfo | None: """Get complete file information by filename""" return self._filename_to_info.get(filename) def get_vst_id(self, filename: str) -> str | None: """Get VST ID for filename""" return self._vst_filename_to_id.get(filename) def get_vss_id(self, filename: str) -> str | None: """Get VSS ID for filename""" return self._vss_filename_to_id.get(filename) def get_storage_type(self, filename: str) -> StorageType | None: """Get primary storage type for filename""" info = self._filename_to_info.get(filename) return info.storage_type if info else None def has_vst_file(self, filename: str) -> bool: """Check if filename is available in VST""" return filename in self._vst_filename_to_id def has_vss_file(self, filename: str) -> bool: """Check if filename is available in VSS""" return filename in self._vss_filename_to_id def get_all_filenames(self) -> list[str]: """Get all available filenames""" return list(self._filename_to_info.keys()) def add_local_files(self, local_files_data: dict[str, dict]) -> None: """ Add local file mappings from local file scan. Args: local_files_data: Dictionary of local files Format: { "filename.mp4": { "filename": "filename.mp4", "duration": 120.5, "full_path": "/path/to/filename.mp4" } } """ for filename, file_data in local_files_data.items(): video_info = VideoFileInfo( filename=filename, storage_type=StorageType.LOCAL, storage_id=filename, # Use filename as ID for local files duration=file_data.get("duration", 0.0), local_path=file_data["full_path"], ) self._filename_to_info[filename] = video_info logger.info(f"Added local mapping: {filename} -> {file_data['full_path']}") def get_files_by_storage_type(self, storage_type: StorageType) -> dict[str, VideoFileInfo]: """Get all files of a specific storage type""" return { filename: info for filename, info in self._filename_to_info.items() if info.storage_type == storage_type } def clear(self) -> None: """Clear all mappings""" self._filename_to_info.clear() self._vss_filename_to_id.clear() self._vst_filename_to_id.clear() logger.info("Cleared all file mappings") # Global instance for use across tools file_mapping = FileMapping() async def resolve_video_file( filename: str, start_timestamp: float, end_timestamp: float, vst_download_tool: Any = None ) -> tuple[str, bool]: """ Resolves filename to actual file path for video processing. Uses global file mapping to determine storage backend and download if needed. Args: filename: Video filename (e.g., 'camera1.mp4') start_timestamp: Start time in seconds end_timestamp: End time in seconds vst_download_tool: VST download tool (if available) Returns: Tuple of (actual_file_path, needs_cleanup) - actual_file_path: Local file path to use for processing - needs_cleanup: Whether the file should be deleted after processing """ # Get file information from global mapping file_info = file_mapping.get_file_info(filename) if not file_info: raise ValueError(f"File '{filename}' not found in available video files") logger.info(f"Resolving file: {filename} (storage: {file_info.storage_type.value})") if file_info.storage_type == StorageType.VST: # download VST file clip to temporary location if not vst_download_tool: raise ValueError("VST download tool not available but VST file requested") # Create temporary file for the clip temp_dir = tempfile.mkdtemp(prefix="vst_clip_") start_timestamp_ms = int(start_timestamp * 1000) end_timestamp_ms = int(end_timestamp * 1000) temp_filename = f"clip_{file_info.storage_id}_{start_timestamp_ms}_{end_timestamp_ms}.mp4" logger.info(f"Downloading VST clip: {file_info.storage_id} ({start_timestamp}s-{end_timestamp}s)") # Download clip from VST download_result = await vst_download_tool.ainvoke( input={ "video_id": file_info.storage_id, "filename": temp_filename, "start_time": start_timestamp_ms, "end_time": end_timestamp_ms, "container": "mp4", "asset_path": temp_dir, } ) actual_file_path = download_result.local_file_path logger.info(f"Downloaded VST clip to: {actual_file_path}") return actual_file_path, True # need to cleanup elif file_info.storage_type == StorageType.LOCAL: # For Local file return direct local path local_path = file_info.local_path if not local_path or not os.path.exists(local_path): raise ValueError(f"Local file not found: {local_path}") logger.info(f"Using local file: {filename} -> {local_path}") return local_path, False # No cleanup needed elif file_info.storage_type == StorageType.VSS: raise NotImplementedError("VSS storage type not yet supported for video file resolution") else: raise ValueError(f"Unknown storage type: {file_info.storage_type}") ================================================ FILE: agent/src/vss_agents/utils/frame_select.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 import logging import math import shutil import subprocess import cv2 logger = logging.getLogger(__name__) def frame_select(video_path: str, start_timestamp: float, end_timestamp: float, step_size: float) -> list[str]: """ Select frames from a video using OpenCV. Args: video_path: Path to the video file start_timestamp: Start time in seconds end_timestamp: End time in seconds step_size: Time interval between frames in seconds Returns: List of base64 encoded JPEG frame images """ cap = cv2.VideoCapture(video_path) if not cap.isOpened(): logger.error(f"Could not open video file: {video_path}") raise ValueError(f"Could not open video file: {video_path}") try: # Get video properties fps = cap.get(cv2.CAP_PROP_FPS) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # Calculate frame indices start_frame = min(total_frames - 1, math.floor(start_timestamp * fps)) end_frame = min(total_frames - 1, math.ceil(end_timestamp * fps)) step_size_frame = max(1, math.floor(step_size * fps)) frame_selection = list(range(start_frame, end_frame, step_size_frame)) if len(frame_selection) == 0: logger.warning(f"No frames selected for video {video_path} from {start_timestamp} to {end_timestamp}") return [] base64_frames = [] for frame_idx in frame_selection: # Seek to the specific frame cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) ret, frame = cap.read() if ret: # Convert frame to base64 JPEG _, buffer = cv2.imencode(".jpg", frame) base64_frames.append(base64.b64encode(buffer.tobytes()).decode("utf-8")) else: raise ValueError(f"Could not read frame {frame_idx} from {video_path}") return base64_frames except Exception as e: raise RuntimeError(f"Error selecting frames from video {video_path}: {e}") from None finally: cap.release() def has_nvidia_gpu() -> bool: """Simple check for NVIDIA GPU""" return ( shutil.which("nvidia-smi") is not None and subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0 ) ================================================ FILE: agent/src/vss_agents/utils/markdown_parser.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import re from typing import Any _time_marker = re.compile(r"\[\s*(?:\d+\.\d{1,2}s?|\d{1,2}:\d{2}s?)(?:\s*-\s*(?:\d+\.\d{1,2}s?|\d{1,2}:\d{2}s?))?\s*\]") _img_tag_marker = re.compile(r"]*>", re.IGNORECASE) def parse_table_or_blocktext( table_lines: list[str], textblock_lines: list[str] | None = None, ) -> dict[str, str | list[str]] | str: """Parse markdown table lines into a dictionary, or joined block text when no table.""" if textblock_lines is None: textblock_lines = [] result: dict[str, str | list[str]] = {} if not table_lines: if textblock_lines: cleaned_lines = [] for text in textblock_lines: clean_txt = _img_tag_marker.sub("", text).strip() if clean_txt: cleaned_lines.append(clean_txt) return _time_marker.sub("", "".join(cleaned_lines)).strip() return result for line in table_lines: line = line.strip() if not line or line.startswith("|---") or line == "|": continue parts = [p.strip().strip("*") for p in line.split("|")] parts = [p for p in parts if p] if len(parts) >= 2 and parts[0].lower() != "field": result[parts[0]] = parts[1] if len(parts) == 2 else parts[1:] return result def parse_markdown_to_json(content: str) -> dict[str, Any]: """Parse markdown content into a structured JSON format.""" lines = content.split("\n") result: dict[str, Any] = {} current_section: str | None = None current_subsection: str | None = None table_lines: list[str] = [] textblock_lines: list[str] = [] i = 0 while i < len(lines): line = lines[i].strip() if line.startswith("# "): result["title"] = line[2:].strip() elif line.startswith("## "): if (table_lines or textblock_lines) and current_section: if current_subsection: if current_section not in result: result[current_section] = {} result[current_section][current_subsection] = parse_table_or_blocktext(table_lines, textblock_lines) else: result[current_section] = parse_table_or_blocktext(table_lines, textblock_lines) table_lines = [] textblock_lines = [] current_section = line[3:].strip() current_subsection = None elif line.startswith("### "): if (table_lines or textblock_lines) and current_section: if current_subsection: if current_section not in result: result[current_section] = {} result[current_section][current_subsection] = parse_table_or_blocktext(table_lines, textblock_lines) else: if current_section is not None and current_section not in result: result[current_section] = {} table_lines = [] textblock_lines = [] current_subsection = line[4:].strip() if current_section is not None and current_section not in result: result[current_section] = {} elif line.startswith("|"): table_lines.append(line) elif line.startswith("**Incident Snapshot:**"): if "Resources" not in result: result["Resources"] = {} # Try to find URL in parentheses on current line match = re.search(r"\((http[^)]+)\)", line) if match: result["Resources"]["Incident Snapshot"] = match.group(1) # Otherwise check next line for plain URL elif i + 1 < len(lines): next_line = lines[i + 1].strip() url_match = re.match(r"(https?://\S+)", next_line) if url_match: result["Resources"]["Incident Snapshot"] = url_match.group(1) elif line.startswith("**Incident Video:**"): if "Resources" not in result: result["Resources"] = {} # Try to find URL in parentheses on current line match = re.search(r"\((http[^)]+)\)", line) if match: result["Resources"]["Incident Video"] = match.group(1) # Otherwise check next non-empty line for plain URL. # FIX: Looks up to 2 lines ahead and skips blank lines, because the # URL may be separated from the label by a blank line (paragraph break # added to prevent PDF justify-spacing issues). else: for j in range(i + 1, min(i + 3, len(lines))): next_line = lines[j].strip() if not next_line: continue url_match = re.match(r"(https?://\S+)", next_line) if url_match: result["Resources"]["Incident Video"] = url_match.group(1) break # only exit once we've found a URL; non-URL lines are skipped so we keep scanning elif current_section == "Analysis Results" and line: textblock_lines.append(line.strip()) i += 1 # Handle remaining table at the end if (table_lines or textblock_lines) and current_section: if current_subsection: if current_section not in result: result[current_section] = {} result[current_section][current_subsection] = parse_table_or_blocktext(table_lines, textblock_lines) else: result[current_section] = parse_table_or_blocktext(table_lines, textblock_lines) return result ================================================ FILE: agent/src/vss_agents/utils/parser.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import ast from contextlib import suppress import re from typing import Any import uuid from langchain_core.exceptions import LangChainException class ReActOutputParserError(ValueError, LangChainException): def __init__( self, observation: str | None = None, missing_action: bool = False, missing_action_input: bool = False, final_answer_and_action: bool = False, ) -> None: self.observation = observation self.missing_action = missing_action self.missing_action_input = missing_action_input self.final_answer_and_action = final_answer_and_action def parse_function_calls(text: str) -> list[dict[str, Any]]: """ Parse a list of function calls from a string like: "[video_caption(file_path='...', start_timestamp=5, ...), video_caption(file_path='...', start_timestamp=5, ...)]" """ # Extract all function name and parameters matches text = text.strip() pattern = r"(\w+)\((.*?)\)" matches = re.findall(pattern, text) if not matches: raise ReActOutputParserError( observation=f"No function calls found in the output: {text}", ) parsed_calls = [] for function_name, params_str in matches: # Parse parameters params = {} if params_str.strip(): # Split by comma, but be careful with quoted strings and nested structures param_pairs = [] current_param = "" in_quotes = False quote_char = None brace_count = 0 bracket_count = 0 paren_count = 0 for char in params_str: if char in ["'", '"'] and (not in_quotes or char == quote_char): if not in_quotes: in_quotes = True quote_char = char else: in_quotes = False quote_char = None elif not in_quotes: if char == "{": brace_count += 1 elif char == "}": brace_count -= 1 elif char == "[": bracket_count += 1 elif char == "]": bracket_count -= 1 elif char == "(": paren_count += 1 elif char == ")": paren_count -= 1 elif char == "," and brace_count == 0 and bracket_count == 0 and paren_count == 0: param_pairs.append(current_param.strip()) current_param = "" continue current_param += char if current_param.strip(): param_pairs.append(current_param.strip()) # Parse each parameter for param in param_pairs: if "=" in param: key, value = param.split("=", 1) key = key.strip() value = value.strip() # Parse the value if (value.startswith("'") and value.endswith("'")) or ( value.startswith('"') and value.endswith('"') ): value = value[1:-1] # Remove quotes else: # Try to parse as Python literal first with suppress(ValueError, SyntaxError): value = ast.literal_eval(value) # If that fails and it looks like JSON, try JSON parsing if isinstance(value, str) and (value.startswith("{") or value.startswith("[")): try: import json value = json.loads(value) except (json.JSONDecodeError, ValueError): pass # Keep as string if JSON parsing fails params[key] = value parsed_calls.append({"name": function_name, "args": params, "id": str(uuid.uuid4())}) return parsed_calls ================================================ FILE: agent/src/vss_agents/utils/reasoning_parsing.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any def parse_content_blocks(response: Any) -> tuple[str | None, str | None]: """Extract reasoning and text from content_blocks on a response. Args: response: LLM response object that may have a content_blocks attribute. Returns: tuple: (reasoning, text) where either can be None if empty/not found. """ blocks = getattr(response, "content_blocks", None) if not blocks or not isinstance(blocks, list): return None, None reasoning_parts = [] text_parts = [] for block in blocks: if not isinstance(block, dict): continue if block.get("type") == "reasoning": reasoning_parts.append(block.get("reasoning", "")) elif block.get("type") == "text": text_parts.append(block.get("text", "")) reasoning = "\n".join(reasoning_parts).strip() or None text = "\n".join(text_parts).strip() or None return reasoning, text def parse_reasoning_content(response: Any) -> tuple[str | None, str | None]: """ Generic parser that extracts reasoning and content from LLM response objects. This function handles multiple formats by trying to find the reasoning content in the following order: 1. Content with single tag delimiter 2. Content with paired tags 3. Response objects with separate reasoning_content field 4. content_blocks with "reasoning" typed blocks 5. Plain content without reasoning Args: response: LLM response object Returns: tuple: (reasoning_content, actual_content) where either can be None if empty/not found """ content = getattr(response, "content", "") # If content is not a string, skip think-tag parsing if not isinstance(content, str): content = "" # Check for single tag (format where everything before is reasoning) if "" in content and "" not in content: think_end_idx = content.find("") reasoning_part = content[:think_end_idx] actual_content = content[think_end_idx + len("") :] reasoning = reasoning_part.strip("\n").strip() actual = actual_content.strip("\n").strip() return reasoning or None, actual or None # Check for paired tags if "" in content and "" in content: think_start_idx = content.find("") think_end_idx = content.find("") # Make sure both tags are in the right order if think_start_idx != -1 and think_end_idx != -1 and think_start_idx < think_end_idx: reasoning_part = content[think_start_idx + len("") : think_end_idx] actual_content = content[think_end_idx + len("") :] reasoning = reasoning_part.strip("\n").strip() actual = actual_content.strip("\n").strip() return reasoning or None, actual or None # No think tags in content, fall back to reasoning_content field # Check for reasoning_content in multiple locations reasoning_field = getattr(response, "reasoning_content", None) if not reasoning_field and hasattr(response, "additional_kwargs"): additional_kwargs = getattr(response, "additional_kwargs", {}) if isinstance(additional_kwargs, dict): reasoning_field = additional_kwargs.get("reasoning_content") if not reasoning_field and hasattr(response, "response_metadata"): response_metadata = getattr(response, "response_metadata", {}) if isinstance(response_metadata, dict): reasoning_field = response_metadata.get("reasoning_content") if reasoning_field and isinstance(reasoning_field, str): return reasoning_field.strip() or None, content.strip() if content else None # Check for content_blocks block_reasoning, block_text = parse_content_blocks(response) if block_reasoning is not None or block_text is not None: return block_reasoning, block_text # No reasoning found, return plain content return None, content or None ================================================ FILE: agent/src/vss_agents/utils/reasoning_utils.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging from typing import Any logger = logging.getLogger(__name__) def get_llm_reasoning_bind_kwargs(llm: Any, llm_reasoning: bool | None) -> dict: """ Get bind kwargs for LLM reasoning. Args: llm: The LLM model instance llm_reasoning: Whether reasoning mode is enabled Returns: Dict with reasoning parameters if applicable, empty dict otherwise """ model_name = getattr(llm, "model_name", "") or getattr(llm, "model", "") model_name = model_name.lower() if type(llm).__name__ == "ChatNVIDIA": if "gpt-oss" in model_name and llm_reasoning is not None: return {"reasoning_effort": "low"} if llm_reasoning is False else {"reasoning_effort": "medium"} if "nemotron-3" in model_name and llm_reasoning is not None: return {"chat_template_kwargs": {"enable_thinking": llm_reasoning}} elif type(llm).__name__ == "ChatOpenAI": return {"reasoning": {"effort": "medium", "summary": "auto"}} if llm_reasoning else {} else: logger.warning(f"models using {type(llm).__name__} is not supported for reasoning binding") return {} logger.warning(f"No reasoning binding for {model_name} (llm_reasoning={llm_reasoning})") return {} # Reference: https://github.com/NVIDIA/NeMo-Agent-Toolkit/blob/develop/src/nat/data_models/thinking_mixin.py # The keys are the fields that are used to determine if the model supports thinking _MODEL_KEYS = ("model_name", "model", "azure_deployment") def get_thinking_tag(llm: Any, thinking: bool | None) -> str | None: """ Returns the system prompt to use for thinking. For NVIDIA Nemotron, returns "/think" if enabled, else "/no_think". For Llama Nemotron v1.5, returns "/think" if enabled, else "/no_think". For Llama Nemotron v1.0 or v1.1, returns "detailed thinking on" if enabled, else "detailed thinking off". Args: llm: The LLM object. thinking: Whether to enable thinking (True, False, or None). Returns: str | None: The thinking tag to append to the system prompt, or None if not applicable. Raises: ValueError: If thinking is not supported on the model but thinking is True. """ if thinking is None: return None for key in _MODEL_KEYS: model = getattr(llm, key, None) if not isinstance(model, str) or model is None: continue # Normalize name to reduce checks model = model.lower().translate(str.maketrans("_.", "--")) if model.startswith("nvidia/nvidia"): if "nemotron-3" in model: return None # Nemotron 3 Nano does not need thinking tag return "/think" if thinking else "/no_think" if model.startswith("nvidia/llama"): if "v1-0" in model or "v1-1" in model or model.endswith("v1"): return f"detailed thinking {'on' if thinking else 'off'}" if "v1-5" in model: # v1.5 models are updated to use the /think and /no_think system prompts return "/think" if thinking else "/no_think" # Assume any other model is a newer model that uses the /think and /no_think system prompts return "/think" if thinking else "/no_think" # Unknown model return None ================================================ FILE: agent/src/vss_agents/utils/retry.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging from aiohttp import ClientConnectorError from aiohttp import ConnectionTimeoutError from tenacity import AsyncRetrying from tenacity import before_sleep_log from tenacity import retry_if_exception_type from tenacity import stop_after_attempt from tenacity import wait_random logger = logging.getLogger(__name__) def create_retry_strategy( retries: int, delay: int | float = 2, exceptions: tuple = (ClientConnectorError, ConnectionTimeoutError) ) -> AsyncRetrying: """ Create a retry strategy. Args: retries: The number of retries to attempt. delay: The delay between retries in seconds. exceptions: The exceptions to retry on. Returns: An AsyncRetrying object. """ return AsyncRetrying( retry=retry_if_exception_type(exceptions), stop=stop_after_attempt(retries), wait=wait_random(min=delay, max=delay * 3), before_sleep=before_sleep_log(logger, logging.WARNING), reraise=True, ) ================================================ FILE: agent/src/vss_agents/utils/time_convert.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from datetime import UTC from datetime import datetime # Standard internal timestamp format is ISO 8601 with trailing Z # Python datetime objects take in tz appended ISO 8601 string as input def datetime_to_iso8601(dt: datetime) -> str: """Convert datetime to ISO 8601 string. (e.g., '2025-08-25T03:05:55.752Z')""" return tz_timestamp_to_utc_timestamp(dt.isoformat()) def iso8601_to_datetime(timestamp: str) -> datetime: """Convert ISO 8601 string (e.g., '2025-08-25T03:05:55.752Z') to datetime.""" dt = datetime.fromisoformat(utc_timestamp_to_tz_timestamp(timestamp)) if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) return dt def utc_timestamp_to_tz_timestamp(timestamp: str) -> str: """ Convert UTC timestamp to timezone timestamp. (e.g., '2025-08-25T03:05:55.752Z' -> '2025-08-25T03:05:55.752+00:00') """ return timestamp.replace("Z", "+00:00") def tz_timestamp_to_utc_timestamp(timestamp: str) -> str: """ Convert timezone timestamp to UTC timestamp. (e.g., '2025-08-25T03:05:55.752+00:00' -> '2025-08-25T03:05:55.752Z') """ return timestamp.replace("+00:00", "Z") ================================================ FILE: agent/src/vss_agents/utils/time_measure.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging import sys import time logger = logging.getLogger(__name__) LOG_PERF_LEVEL = 15 LOG_STATUS_LEVEL = 16 logging.addLevelName(LOG_PERF_LEVEL, "PERF") logging.addLevelName(LOG_STATUS_LEVEL, "STATUS") class TimeMeasure: """Measures the execution time of a block of code. This class is used as a context manager. """ def __init__(self, string: str, print: bool = True) -> None: """Class constructor Args: string (str): A string to identify the code block while printing the execution time. print (bool, optional): Print the execution time. Defaults to True. """ self._string = string self._print = print def __enter__(self) -> "TimeMeasure": self._start_time = time.perf_counter() logger.debug("[START] " + self._string) return self def __exit__(self, type: type[BaseException] | None, value: BaseException | None, traceback: object) -> None: self._end_time = time.perf_counter() logger.debug("[END] " + self._string) if self._print: exec_time = self._end_time - self._start_time if exec_time > 1: exec_time, unit = exec_time, "sec" elif exec_time > 0.001: exec_time, unit = exec_time * 1000.0, "millisec" elif exec_time > 1e-6: exec_time, unit = exec_time * 1e6, "usec" else: exec_time, unit = exec_time * 1e9, "nanosec" logger.log( LOG_PERF_LEVEL, f"{self._string:s} execution time = {exec_time:.3f} {unit:s}", ) print( f"{self._string:s} execution time = {exec_time:.3f} {unit:s}", file=sys.stderr, ) logger.debug(f"{self._string} start={self._start_time!s} end={self._end_time!s}") @property def execution_time(self) -> float: """Execution time of the code block. Should be used once the code block is finished executing. Returns: float: Execution time in seconds """ return self._end_time - self._start_time @property def current_execution_time(self) -> float: """Current execution time of the code block. Can be used inside the code block. Returns: float: Execution time in seconds """ return time.perf_counter() - self._start_time ================================================ FILE: agent/src/vss_agents/utils/url_translation.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ URL Translation Utility for VSS Agent. Translates URLs based on VLM_MODE to ensure VLM can access video resources: - remote: INTERNAL_IP -> EXTERNAL_IP (external VLM needs public URLs) - local/local_shared: EXTERNAL_IP -> INTERNAL_IP (local VLM needs internal URLs) When the application is behind a reverse proxy (e.g., Brev secure links routing through nginx), the video URL hostname won't match either IP. In that case, if ``vst_internal_url`` is provided, the proxy base URL is replaced with the internal VST base URL so the local VLM can reach the video directly. Configuration (passed as arguments from tool config): vlm_mode: remote / local / local_shared external_ip: Public IP accessible from the internet internal_ip: Internal IP / docker host IP vst_internal_url: (optional) Internal VST base URL for proxy fallback """ import logging from urllib.parse import ParseResult from urllib.parse import urlparse from urllib.parse import urlunparse logger = logging.getLogger(__name__) def translate_url( url: str, vlm_mode: str | None, internal_ip: str | None, external_ip: str | None, vst_internal_url: str | None = None, ) -> str: """Translate URL based on VLM_MODE. - remote: Replace INTERNAL_IP with EXTERNAL_IP (VLM is external, needs public URLs) - local/local_shared: Replace EXTERNAL_IP with INTERNAL_IP (VLM is local, needs internal URLs) When the URL host doesn't match either IP (e.g., behind a reverse proxy), falls back to replacing the base URL with ``vst_internal_url`` if provided. Args: url: The URL to translate vlm_mode: VLM mode ('remote', 'local', or 'local_shared'), None to skip translation internal_ip: Internal IP / docker host IP, None to skip translation external_ip: Public IP accessible from the internet, None to skip translation vst_internal_url: Internal VST base URL (e.g., 'http://10.0.0.1:30888'). Used as fallback when the URL host is a proxy hostname that doesn't match either IP. Only applies to local/local_shared modes. Returns: Translated URL, or original URL if no translation needed """ if not url: return url # Validate vlm_mode if not vlm_mode: logger.warning( "URL TRANSLATION: vlm_mode is not set. " "Expected values: 'remote', 'local', or 'local_shared'. " "URL translation will be skipped." ) return url vlm_mode = vlm_mode.lower() # Check for missing external_ip if not external_ip: logger.error( "URL TRANSLATION ERROR: external_ip is not set! " "Set external_ip to the public IP accessible from the internet. " "URLs will NOT be translated." ) return url # Check for missing internal_ip if not internal_ip: logger.error( "URL TRANSLATION ERROR: internal_ip is not set! " "Set internal_ip to the internal/docker host IP. " "URLs will NOT be translated." ) return url # Check if IPs are the same (no translation needed) if external_ip == internal_ip: logger.debug(f"URL TRANSLATION: external_ip ({external_ip}) equals internal_ip - no translation needed.") return url # Parse the URL parsed = urlparse(url) if not parsed.netloc: return url # Extract host (without port) host = parsed.netloc.split(":")[0] # Determine translation direction based on vlm_mode if vlm_mode == "remote": # Remote VLM needs external/public URLs source_ip = internal_ip target_ip = external_ip direction = "INTERNAL -> EXTERNAL" elif vlm_mode in ("local", "local_shared"): # Local VLM needs internal URLs source_ip = external_ip target_ip = internal_ip direction = "EXTERNAL -> INTERNAL" else: logger.warning( f"URL TRANSLATION: Unknown vlm_mode '{vlm_mode}'. Expected: 'remote', 'local', or 'local_shared'." ) return url # Only translate if the host matches the source IP if host != source_ip: # Proxy fallback: when the app is behind a reverse proxy (e.g., Brev # secure links with nginx), the URL hostname is the proxy's hostname, # not a direct IP. For local VLM modes, replace the proxy base URL # with the internal VST URL so the VLM can reach the video directly. if vlm_mode in ("local", "local_shared") and vst_internal_url: return _translate_proxy_url(url, parsed, vst_internal_url) logger.debug(f"URL TRANSLATION: Host '{host}' does not match source IP '{source_ip}' - no translation needed.") return url # Replace source IP with target IP in netloc new_netloc = parsed.netloc.replace(source_ip, target_ip, 1) translated = urlunparse(parsed._replace(netloc=new_netloc)) logger.info(f"URL TRANSLATION [{direction}] (vlm_mode={vlm_mode}): Converting IP from {source_ip} to {target_ip}") logger.info(f"URL TRANSLATION: {url} -> {translated}") return translated # Routing table: path prefix -> internal port. # Used to resolve proxy URLs (no explicit port) to the correct internal service. # Order matters — longest/most-specific prefixes first. _PROXY_ROUTE_TABLE: list[tuple[str, int]] = [ ("/vst/", 30888), ("/api/v1/", 8000), ("/chat/", 8000), ("/static/", 8000), ("/health", 8000), ("/incidents", 8081), ("/livez", 8081), ] _PROXY_DEFAULT_PORT = 8000 # agent as fallback def rewrite_url_host(url: str, target_ip: str) -> str: """Replace the host in *url* with *target_ip*, preserving path, query, and fragment. When the URL has an explicit port (e.g. ``http://1.2.3.4:30888/...``), the port and scheme are preserved as-is — this is the normal direct-IP case. When there is no explicit port and the host is not already *target_ip*, the URL is assumed to be coming through a reverse proxy (e.g. a Brev secure link like ``https://7777-abc.brevlab.com/vst/...``). In that case the scheme is forced to ``http`` and the port is resolved from the path prefix via :data:`_PROXY_ROUTE_TABLE`. Args: url: The URL to rewrite. target_ip: The IP address to substitute (e.g. ``10.0.1.1``). Returns: URL rewritten to reach the internal service directly. """ parsed = urlparse(url) if parsed.port: # Explicit port — direct-IP URL, simple host swap. new_netloc = f"{target_ip}:{parsed.port}" return urlunparse(parsed._replace(netloc=new_netloc)) host = parsed.hostname or "" if host == target_ip: # Already pointing at target — nothing to do. return url # No explicit port and host != target_ip → proxy URL. # Look up the internal port from the path prefix. port = _PROXY_DEFAULT_PORT path = parsed.path or "/" for prefix, p in _PROXY_ROUTE_TABLE: if path.startswith(prefix): port = p break new_netloc = f"{target_ip}:{port}" translated = urlunparse(parsed._replace(scheme="http", netloc=new_netloc)) logger.info(f"URL REWRITE [PROXY -> INTERNAL]: {url} -> {translated}") return translated def _translate_proxy_url(url: str, parsed: ParseResult, vst_internal_url: str) -> str: """Replace a proxy base URL with the internal VST base URL. When behind a reverse proxy, the video URL looks like: https://proxy-host:port/vst/storage/file.mp4 The internal VST URL is: http://internal-ip:30888 So the translated URL becomes: http://internal-ip:30888/vst/storage/file.mp4 The path is preserved as-is since the proxy forwards ``/vst/`` to VST without rewriting. """ internal_parsed = urlparse(vst_internal_url.rstrip("/")) translated = urlunparse( parsed._replace( scheme=internal_parsed.scheme, netloc=internal_parsed.netloc, ) ) logger.info( f"URL TRANSLATION [PROXY -> INTERNAL] (behind reverse proxy): " f"Replacing proxy base URL with internal VST URL ({vst_internal_url})" ) logger.info(f"URL TRANSLATION: {url} -> {translated}") return translated ================================================ FILE: agent/src/vss_agents/utils/video_file.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging import os import cv2 from vss_agents.data_models.vss import MediaInfoOffset logger = logging.getLogger(__name__) def get_video_duration(file_path: str) -> float: # Check if file exists if not os.path.exists(file_path): logger.error(f"Video file does not exist: {file_path}") return 0.0 video_capture = cv2.VideoCapture(file_path) # Check if video was opened successfully if not video_capture.isOpened(): logger.error(f"Could not open video file: {file_path}") video_capture.release() return 0.0 # Get frame count and FPS frame_count = video_capture.get(cv2.CAP_PROP_FRAME_COUNT) fps = video_capture.get(cv2.CAP_PROP_FPS) video_capture.release() # Check for valid FPS to avoid division by zero if fps <= 0: logger.error(f"Invalid FPS ({fps}) for video file: {file_path}") return 0.0 # Check for valid frame count if frame_count <= 0: logger.error(f"Invalid frame count ({frame_count}) for video file: {file_path}") return 0.0 video_duration = frame_count / fps logger.info(f"Video duration for {file_path}: {video_duration} seconds") return video_duration def pad_media_info(media_info: MediaInfoOffset, video_duration: float, min_chunk_duration: int = 2) -> MediaInfoOffset: """Pad the media info to the minimum chunk duration""" left_padding = min_chunk_duration // 2 if media_info.start_offset > left_padding: media_info.start_offset -= left_padding else: left_padding = media_info.start_offset media_info.start_offset = 0 right_padding = min_chunk_duration - left_padding media_info.end_offset += right_padding if media_info.end_offset > video_duration: media_info.end_offset = int(video_duration) return media_info ================================================ FILE: agent/src/vss_agents/video_analytics/README.md ================================================ # Video Analytics Tools NAT function group providing video analytics tools for querying incident, behavior, and metadata from Elasticsearch. ## Available Tools - **get_incident**: Get a specific incident by ID - **get_incidents**: Get incidents from a sensor or place/city - **get_sensor_ids**: Get list of available sensor IDs (optionally filtered by place) - **get_places**: Get list of available places - **get_fov_histogram**: Get FOV histogram with object count statistics over time (people, vehicles, etc.) - **get_average_speeds**: Get average speed metrics - **analyze**: Perform analysis on video analytics data ### Source Type Options When querying incidents with `get_incidents`, you can specify the `source_type` parameter: - **sensor**: Query by specific sensor ID (exact match) - **place**: Query by place name using wildcard matching. Works for both city names and intersection names. - Example: `source="Dubuque"` with `source_type="place"` matches all incidents in Dubuque - Example: `source="HWY_20_AND_LOCUST"` with `source_type="place"` matches that specific intersection **Note:** The `vlm_verdict` parameter can only be used when `vlm_verified` is set to `true` in the configuration. Attempting to use it when `vlm_verified` is `false` will result in a validation error. ## Quick Start: Serve via MCP ### 1. Set up config file. Use the `va_mcp_server_config.yml` as a guide. Example config file setup: ```yaml functions: vst_sensor_list: _type: mcp_tool_wrapper url: http://localhost:8001/mcp mcp_tool_name: sensor_list function_groups: video_analytics: _type: video_analytics es_url: "http://localhost:9200" index_prefix: "mdx-" vlm_verified: false embedding_model_name: "sentence-transformers/all-MiniLM-L6-v2" vst_sensor_list_tool: vst_sensor_list include: - get_incident - get_incidents - get_sensor_ids - get_places - get_fov_histogram - get_average_speeds - analyze ``` Note that a dummy workflow is required by NAT. ### 2. Start the MCP Server Edit the config file variables and then run: ```bash nat mcp serve --config_file deployments/warehouse/vss-agent/configs/va_mcp_server_config.yml ``` The server will start on `http://localhost:9901/mcp` by default. ### 3. Connect NAT Workflow as Client You can now invoke these tools using your workflow's standard tool-calling interface. Make sure your NAT workflow is configured to connect to the same server URL where MCP is running. ```yaml function_groups: video_analytics_mcp: _type: mcp_client server: transport: streamable-http url: "http://localhost:9901/mcp" llms: nim_llm: _type: nim model_name: meta/llama-3.1-70b-instruct temperature: 0.0 max_tokens: 1024 workflow: _type: react_agent tool_names: [video_analytics_mcp] llm_name: nim_llm verbose: true max_retries: 3 ``` ================================================ FILE: agent/src/vss_agents/video_analytics/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/src/vss_agents/video_analytics/embeddings.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Embedding utilities for semantic place search. Provides functionality to encode text into embeddings and perform similarity-based search over cached place embeddings. """ import logging from typing import Any import numpy as np logger = logging.getLogger(__name__) class EmbeddingModel: """ Wrapper around sentence-transformers for generating embeddings. Loads model once at initialization and caches in memory for fast inference. """ model: Any # SentenceTransformer instance def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2"): """ Initialize embedding model. Args: model_name: Name of the sentence-transformers model to use """ self.model_name = model_name self._load_model() def _load_model(self) -> None: """Load the sentence-transformers model.""" try: from sentence_transformers import SentenceTransformer logger.info(f"Loading embedding model: {self.model_name}") self.model = SentenceTransformer(self.model_name) logger.info(f"Successfully loaded embedding model: {self.model_name}") except Exception as e: logger.error(f"Failed to load embedding model {self.model_name}: {e}") raise def encode(self, text: str) -> np.ndarray: """ Encode text into embedding vector. Args: text: Text to encode Returns: Embedding vector as numpy array """ if self.model is None: raise RuntimeError("Video Analytics: Embedding model not loaded") try: embedding: np.ndarray = self.model.encode(text, convert_to_numpy=True) return embedding except Exception as e: logger.error(f"Failed to encode text '{text}': {e}") raise def encode_batch(self, texts: list[str]) -> np.ndarray: """ Encode multiple texts into embedding vectors in a single batch. Args: texts: List of texts to encode Returns: 2D numpy array of shape (len(texts), embedding_dim) """ if self.model is None: raise RuntimeError("Video Analytics: Embedding model not loaded") if not texts: return np.array([]).reshape(0, 0) try: logger.info(f"Batch encoding {len(texts)} texts...") embeddings: np.ndarray = self.model.encode(texts, convert_to_numpy=True, show_progress_bar=True) logger.info(f"Successfully encoded {len(texts)} texts") return embeddings except Exception as e: logger.error(f"Failed to batch encode {len(texts)} texts: {e}") raise class PlaceEmbeddingCache: """ In-memory cache of place name embeddings for fast similarity search. Stores embeddings as numpy arrays and performs cosine similarity search to find semantically similar places. """ def __init__(self) -> None: """Initialize empty cache.""" self.place_names: list[str] = [] self.embeddings: np.ndarray | None = None # Shape: (N, embedding_dim) def add_places_batch(self, names: list[str], embeddings: np.ndarray) -> None: """ Add multiple places and their embeddings to the cache at once. Args: names: List of place names embeddings: 2D array of embeddings, shape (len(names), embedding_dim) """ if len(names) != len(embeddings): raise ValueError(f"Video Analytics: Mismatch: {len(names)} names vs {len(embeddings)} embeddings") if len(names) == 0: return self.place_names.extend(names) if self.embeddings is None: self.embeddings = embeddings else: self.embeddings = np.vstack([self.embeddings, embeddings]) def find_similar( self, query_embedding: np.ndarray, top_k: int = 5, threshold: float = 0.5 ) -> list[tuple[str, float]]: """ Find places similar to the query embedding. Uses cosine similarity to rank places by semantic similarity. Args: query_embedding: Query embedding vector top_k: Maximum number of results to return threshold: Minimum similarity score (0.0-1.0) to include in results Returns: List of (place_name, similarity_score) tuples, sorted by score descending """ if self.embeddings is None or len(self.place_names) == 0: return [] # Compute cosine similarity between query and all cached embeddings # Cosine similarity = dot(A, B) / (norm(A) * norm(B)) query_norm = query_embedding / np.linalg.norm(query_embedding) embeddings_norm = self.embeddings / np.linalg.norm(self.embeddings, axis=1, keepdims=True) # Shape: (N,) - similarity score for each place similarities = np.dot(embeddings_norm, query_norm) # Get top-k indices sorted by similarity descending top_indices = np.argsort(similarities)[::-1][:top_k] # Filter by threshold and build results results = [] for idx in top_indices: score = float(similarities[idx]) if score >= threshold: place_name = self.place_names[idx] results.append((place_name, score)) return results def size(self) -> int: """Return number of places in cache.""" return len(self.place_names) ================================================ FILE: agent/src/vss_agents/video_analytics/es_client.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Shared Elasticsearch client and utilities for video analytics tools.""" from copy import deepcopy from typing import Any from typing import ClassVar from typing import cast from elasticsearch import AsyncElasticsearch BASE_QUERY_TEMPLATE: dict[str, dict[str, dict[str, list]]] = { "query": {"bool": {"must": [], "filter": [], "should": [], "must_not": []}} } class ESClient: """ Shared Elasticsearch client with common utilities. """ # Whitelist of allowed indexes INDEXES: ClassVar[dict[str, str]] = { "incidents": "incidents-*", "vlm_incidents": "vlm-incidents-*", "behavior": "behavior-*", "frames": "frames-*", "calibration": "calibration", } def __init__(self, es_url: str, index_prefix: str = ""): """ Initialize ES client. Args: es_url: Elasticsearch URL (e.g., "http://localhost:9200") index_prefix: Optional prefix for all indexes """ self.client = AsyncElasticsearch([es_url]) self.index_prefix = index_prefix async def close(self) -> None: """Close the Elasticsearch connection.""" await self.client.close() async def __aenter__(self) -> "ESClient": """Async context manager entry.""" return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object ) -> None: """Async context manager exit.""" await self.close() def get_index(self, index_key: str) -> str: """ Get full index name with prefix. Args: index_key: Key from INDEXES dict Returns: Full index name with prefix Raises: ValueError: If index_key is not in whitelist """ if index_key not in self.INDEXES: raise ValueError( f"Video Analytics: Invalid index key '{index_key}', valid keys: {list(self.INDEXES.keys())}" ) return f"{self.index_prefix}{self.INDEXES[index_key]}" async def search( self, index_key: str, query_body: dict, size: int = 100, sort: str | None = None, source_includes: list[str] | None = None, source_excludes: list[str] | None = None, ) -> list[dict]: """ Search Elasticsearch and return matching documents. Similar to Elasticsearch.getSearchResults() in web-api-core Args: index_key: Index to search (from INDEXES whitelist) query_body: Elasticsearch query body size: Maximum number of results to return sort: Sort specification (e.g., "timestamp:desc") source_includes: List of fields to include in response (filters at ES level) source_excludes: List of fields to exclude from response Returns: List of document _source objects """ index = self.get_index(index_key) # Check if index exists index_exists = await self.client.indices.exists(index=index) if not index_exists: return [] # Create a copy of query_body to avoid modifying the original query_body_copy = deepcopy(query_body) # Add sort to query body # Parse "field:order" format into [{"field": {"order": "order"}}] if sort: if isinstance(sort, str) and ":" in sort: field, order = sort.split(":", 1) query_body_copy["sort"] = [{field: {"order": order}}] else: query_body_copy["sort"] = sort query_body_copy["size"] = size response = await self.client.search( index=index, body=query_body_copy, source_includes=source_includes if source_includes else None, source_excludes=source_excludes if source_excludes else None, ) # Format results return [hit["_source"] for hit in response["hits"]["hits"]] async def aggregate(self, index_key: str, query_body: dict, aggs: dict) -> dict: """ Run aggregation query and return results. Args: index_key: Index to search (from INDEXES whitelist) query_body: Elasticsearch query body (will be copied, not modified) aggs: Aggregation specification Returns: Aggregation results dictionary """ index = self.get_index(index_key) # Check if index exists index_exists = await self.client.indices.exists(index=index) if not index_exists: return {} # Copy query body to avoid modifying the original query_with_aggs = deepcopy(query_body) query_with_aggs["aggs"] = aggs response = await self.client.search( index=index, body=query_with_aggs, size=0, # Only want aggregations, not documents ) return cast("dict[Any, Any]", response.get("aggregations", {})) async def get_by_id(self, index_key: str, doc_id: str) -> dict | None: """ Get a single document by ID. Similar to getting calibration by ID in web-api-core Args: index_key: Index to search (from INDEXES whitelist) doc_id: Document ID Returns: Document _source or None if not found """ index = self.get_index(index_key) # Check if index exists index_exists = await self.client.indices.exists(index=index) if not index_exists: return None query_body = {"query": {"ids": {"values": [doc_id]}}, "size": 1} response = await self.client.search(index=index, body=query_body) hits = response.get("hits", {}).get("hits", []) if hits: return cast("dict[Any, Any]", hits[0]["_source"]) return None async def count(self, index_key: str, query_body: dict) -> int: """ Count documents matching a query. Uses Elasticsearch count API which is efficient and doesn't have the 10,000 result window limit. Args: index_key: Index to search (from INDEXES whitelist) query_body: Elasticsearch query body Returns: Count of matching documents """ index = self.get_index(index_key) # Check if index exists index_exists = await self.client.indices.exists(index=index) if not index_exists: return 0 response = await self.client.count(index=index, body=query_body) return cast("int", response.get("count", 0)) ================================================ FILE: agent/src/vss_agents/video_analytics/interface.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from abc import ABC from abc import abstractmethod from enum import StrEnum from typing import TYPE_CHECKING if TYPE_CHECKING: from deep_search.data_models.nvschema import Incident class IncidentMetadata(StrEnum): PLACE = "place" CATEGORY = "category" IS_ANOMALY = "isAnomaly" OBJECT_IDS = "objectIds" FRAME_IDS = "frameIds" ANALYTICS_MODULE = "analyticsModule" TYPE = "type" INFO = "info" class VideoAnalyticsInterface(ABC): """ Interface class for video analytics system. """ @abstractmethod async def get_incident( self, id: str, *, includes: list[IncidentMetadata] | None = None, ) -> "Incident | None": """ Get a specific incident by ID from the video analytics system. Returns the complete incident data including all available fields unless limited by the includes parameter. Input: id: str The incident ID to retrieve. includes: list[IncidentMetadata] | None The metadata fields to include in the output. Output: Incident | None: The incident data, or None if not found. """ pass @abstractmethod async def get_incidents( self, start_time: str | None = None, end_time: str | None = None, *, source: str | None = None, source_type: str | None = None, # Must be "sensor" or "place" if source is provided max_count: int = 10, includes: list[IncidentMetadata] | None = None, vlm_verdict: str | None = None, # Must be "all", "confirmed", "rejected", "verification-failed", or "not-confirmed" ) -> tuple[list["Incident"], bool]: """ Get incidents from the video analytics system. By default, only the most recent 10 incidents will be returned and each incident only contains the incident id, start time, end time and sensor id unless additional fields are requested via includes. If source and source_type are omitted, all incidents will be queried (filtered by time range if provided). If start_time and end_time are omitted, returns the most recent incidents up to max_count. Input: start_time: str | None Optional start time of the incidents (ISO format). If omitted, returns the most recent incidents up to max_count. end_time: str | None Optional end time of the incidents (ISO format). If omitted, returns the most recent incidents up to max_count. source: str | None Optional source of the incidents (sensor ID or place/city name). If provided, source_type must also be provided. source_type: Literal["sensor", "place"] | None The type of the source. 'place' uses wildcard matching and can match city names or intersection names. Required if source is provided. max_count: int The maximum number of incidents to return. includes: list[IncidentMetadata] The metadata to be included in the output. vlm_verdict: Literal["all", "confirmed", "rejected", "verification-failed", "not-confirmed"] | None Optional VLM verdict filter. Can only be used when vlm_verified config is enabled. Output: (list[Incident], bool): The list of incidents and a boolean flag indicating if there are more incidents available. """ pass @abstractmethod async def get_sensor_ids(self, place: str | None = None) -> list[str]: """ Get the list of sensor IDs from calibration configuration, optionally filtered by place. Input: place: str | None Optional place name to filter sensor IDs Output: list[str]: List of sensor IDs """ pass @abstractmethod async def get_places(self) -> dict: """ Get the hierarchical map of all available places Returns the place_map structure: city -> [intersection] Output: dict: Hierarchical place map with structure: { "city_name": ["intersection1", "intersection2", ...], ... } """ pass @abstractmethod async def get_fov_histogram( self, source: str, start_time: str, end_time: str, object_type: str | None = None, bucket_count: int = 10, ) -> dict: """ Returns FOV occupancy histogram with time buckets showing object counts over time. Queries frames index with nested fov field. Input: source: str The source of the object counts (sensor ID). start_time: str The start time of query (ISO format). end_time: str The end time of query (ISO format). object_type: str | None Optional type of the object to filter by. bucket_count: int Number of time buckets for histogram (default: 10). Output: dict: Histogram with structure: { "bucketSizeInSec": 180, "histogram": [ { "start": "2023-01-12T11:20:10.000Z", "end": "2023-01-12T11:23:10.000Z", "objects": [ {"type": "Person", "averageCount": 5}, {"type": "Vehicle", "averageCount": 2} ] }, ... ] } """ pass @abstractmethod async def get_average_speeds( self, source: str, start_time: str, end_time: str, source_type: str, # Must be "sensor" or "place" ) -> dict: """ Returns average speed per direction at source. Queries behavior index and groups by direction. Input: source: str The source of the query (sensor ID or place name). start_time: str The start time of query (ISO format). end_time: str The end time of query (ISO format). source_type: Literal["sensor", "place"] The type of the source. Output: dict: Average speed metrics per direction { "metrics": [ {"direction": "North", "averageSpeed": "25 mph"}, {"direction": "South", "averageSpeed": "30 mph"} ] } """ pass @abstractmethod async def analyze( self, start_time: str, end_time: str, source: str, source_type: str, # Must be "sensor" or "place" analysis_type: str, # Must be one of: "max_min_incidents", "average_speed", "avg_num_people", "avg_num_vehicles" ) -> str: """ Analyze the incidents in the video analytics system. Input: start_time: str The start time of the incidents. end_time: str The end time of the incidents. source: str source_type: str The type of the source. Must be "sensor" or "place". analysis_type: str The type of the analysis. Must be one of: "max_min_incidents", "average_speed", "avg_num_people", "avg_num_vehicles". - max_min_incidents: Returns both min and max overlapping incidents - average_speed: Returns average speeds per direction - avg_num_people: Returns average number of people detected over time - avg_num_vehicles: Returns average number of vehicles detected over time Output: str: The analysis result in natural language. """ pass ================================================ FILE: agent/src/vss_agents/video_analytics/nvschema.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field class Location(BaseModel): latitude: float = Field(0, description="Latitude of the location", alias="lat") longitude: float = Field(0, description="Longitude of the location", alias="lon") altitude: float = Field(0, description="Altitude of the location", alias="alt") class Coordinates(BaseModel): latitude: float = Field(0, description="Latitude of the coordinates", alias="lat") longitude: float = Field(0, description="Longitude of the coordinates", alias="lon") altitude: float = Field(0, description="Altitude of the coordinates", alias="alt") class Place(BaseModel): id: str = Field("...", description="ID of the place where the incident occurred", alias="id") name: str = Field("...", description="Name of the place where the incident occurred", alias="name") place_type: str = Field("...", description="Type of the place where the incident occurred", alias="type") location: Location | None = Field(None, description="Location of the place where the incident occurred") coordinates: Coordinates | None = Field(None, description="Coordinates of the place where the incident occurred") class Incident(BaseModel): """ Pydantic model for NVSchema Incident. This model is used to represent incidents from the video analytics system. It contains both required fields (always present) and optional metadata fields. """ model_config = ConfigDict(populate_by_name=True, extra="allow") # Required fields (always included) id: str = Field("...", description="Incident ID", alias="Id") sensor_id: str = Field("...", description="Sensor ID where the incident occurred", alias="sensorId") start_time: str = Field("...", description="Start time of the incident (ISO format)", alias="timestamp") end_time: str = Field("...", description="End time of the incident (ISO format)", alias="end") # Optional metadata fields (included based on 'includes' parameter) place: Place | None = Field(None, description="Place where the incident occurred") category: str | None = Field(None, description="Category of the incident") object_ids: list[str] | None = Field( None, description="Array of object IDs involved in the incident", alias="objectIds", ) frame_ids: list[str] | None = Field( None, description="Array of frame IDs associated with the incident", alias="frameIds", ) analytics_module: str | None = Field( None, description="Analytics module that detected the incident", alias="analyticsModule" ) info: dict[str, Any] | None = Field(None, description="Additional incident information") incident_type: str | None = Field(None, description="Type of the incident", alias="type") is_anomaly: bool | None = Field(None, description="Whether the incident is an anomaly", alias="isAnomaly") ================================================ FILE: agent/src/vss_agents/video_analytics/query_builders.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Domain-specific query builders for video analytics. Each builder knows how to construct queries for a specific domain. """ from copy import deepcopy from .es_client import BASE_QUERY_TEMPLATE class IncidentQueryBuilder: """ Build incident-specific queries. Supports interface.get_incidents() and interface.get_incident() """ @staticmethod def build_query_by_id(incident_id: str) -> dict: """ Build query for a single incident by exact ID match. Args: incident_id: The incident ID to query Returns: Elasticsearch query body """ query = deepcopy(BASE_QUERY_TEMPLATE) # Exact match on Id.keyword field query["query"]["bool"]["must"].append({"term": {"Id.keyword": incident_id}}) return query @staticmethod def build_query( source: str | None, source_type: str | None, start_time: str | None, end_time: str | None, vlm_verified: bool = False, vlm_verdict: str | None = None, ) -> dict: """ Build query for incidents. Args: source: Optional sensor ID or place/city name (None to query all) source_type: Optional "sensor" or "place" (None to query all). "place" uses wildcard matching. start_time: Optional ISO format timestamp (None to get most recent incidents) end_time: Optional ISO format timestamp (None to get most recent incidents) vlm_verified: Whether VLM verification is enabled vlm_verdict: Optional VLM verdict filter ('all', 'confirmed', 'rejected', 'verification-failed', 'not-confirmed') Returns: Elasticsearch query body """ query = deepcopy(BASE_QUERY_TEMPLATE) # Time range filter (incidents have start and end times) # Only add time filters if timestamps are provided if start_time is not None and end_time is not None: query["query"]["bool"]["must"].extend( [{"range": {"timestamp": {"lte": end_time}}}, {"range": {"end": {"gte": start_time}}}] ) # Source filter (sensor or place) - only if provided if source is not None and source_type is not None: if source_type == "sensor": query["query"]["bool"]["must"].append({"term": {"sensorId.keyword": source}}) elif source_type == "place": # Use wildcard matching to allow partial place name matches # Works for both city names and intersection names # Example: "Dubuque" matches "city=Dubuque/intersection=HWY_20_AND_LOCUST" # pragma: allowlist secret # Example: "HWY_20_AND_LOCUST" matches "city=Dubuque/intersection=HWY_20_AND_LOCUST" # pragma: allowlist secret query["query"]["bool"]["must"].append({"wildcard": {"place.name.keyword": f"*{source}*"}}) # VLM verdict filter - only if vlm_verified is enabled and verdict is provided if vlm_verified and vlm_verdict is not None: if vlm_verdict == "all": # No additional filtering needed pass elif vlm_verdict == "not-confirmed": # Filter for both "rejected" and "verification-failed" query["query"]["bool"]["must"].append( {"terms": {"info.verdict.keyword": ["rejected", "verification-failed"]}} ) else: # Filter for specific verdict (confirmed, rejected, or verification-failed) query["query"]["bool"]["must"].append({"term": {"info.verdict.keyword": vlm_verdict}}) return query class FramesQueryBuilder: """ Build frames-specific queries. Mirrors logic for frames index queries. Supports FOV occupancy queries. """ @staticmethod def build_query(sensor_id: str, start_time: str, end_time: str) -> dict: """ Build query for frames. Args: sensor_id: Sensor ID start_time: ISO format timestamp end_time: ISO format timestamp Returns: Elasticsearch query body """ query = deepcopy(BASE_QUERY_TEMPLATE) # Sensor filter query["query"]["bool"]["must"].append({"term": {"sensorId.keyword": sensor_id}}) # Time range filter query["query"]["bool"]["must"].append({"range": {"timestamp": {"gte": start_time, "lte": end_time}}}) return query @staticmethod def fov_histogram_aggregation(bucket_size_sec: int, object_type: str | None = None) -> dict: """ Histogram aggregation for FOV object counts over time buckets. Uses frames index with nested fov field. Args: bucket_size_sec: Size of each time bucket in seconds object_type: Optional filter for specific object type Returns: Aggregation specification for histogram of FOV occupancy """ agg = { "eventsOverTime": { "date_histogram": {"field": "timestamp", "fixed_interval": f"{bucket_size_sec}s"}, "aggs": { "fov": { "nested": {"path": "fov"}, "aggs": { "searchAggFilter": { "filter": {"bool": {"filter": []}}, "aggs": { "objectType": { "terms": {"field": "fov.type.keyword", "size": 1000}, "aggs": {"avgCount": {"avg": {"field": "fov.count"}}}, } }, } }, } }, } } # Add object type filter if specified if object_type: # Deep nested access - mypy can't track the dict structure events_over_time: dict = agg["eventsOverTime"] fov_aggs: dict = events_over_time["aggs"]["fov"]["aggs"] filter_list: list = fov_aggs["searchAggFilter"]["filter"]["bool"]["filter"] filter_list.append({"term": {"fov.type.keyword": object_type}}) return agg class BehaviorQueryBuilder: """ Build behavior/metrics queries. Supports interface.get_fov_histogram() and interface.get_average_speeds() """ DEFAULT_STATIONARY_OBJECT_MAX_TIME_INTERVAL_SEC = 500 DEFAULT_STATIONARY_OBJECT_MIN_DISTANCE_METERS = 5 DEFAULT_SHORT_LIVED_BEHAVIOR_MIN_TIME_INTERVAL_SEC = 3 @staticmethod def build_average_speed_query(source: str, source_type: str, start_time: str, end_time: str) -> dict: """ Build average speed query. Args: source: Sensor ID or place name source_type: "sensor" or "place" start_time: ISO format timestamp (fromTimestamp) end_time: ISO format timestamp (toTimestamp) Returns: Elasticsearch query body """ query = deepcopy(BASE_QUERY_TEMPLATE) # Time range filter query["query"]["bool"]["must"].extend( [{"range": {"timestamp": {"lte": end_time}}}, {"range": {"end": {"gte": start_time}}}] ) # Filter out short-lived behaviors and stationary objects query["query"]["bool"]["must"].extend( [ { "range": { "timeInterval": { "gte": BehaviorQueryBuilder.DEFAULT_SHORT_LIVED_BEHAVIOR_MIN_TIME_INTERVAL_SEC, "lte": BehaviorQueryBuilder.DEFAULT_STATIONARY_OBJECT_MAX_TIME_INTERVAL_SEC, } } }, {"range": {"distance": {"gte": BehaviorQueryBuilder.DEFAULT_STATIONARY_OBJECT_MIN_DISTANCE_METERS}}}, ] ) # Source filter if source_type == "place": # Use wildcard matching to allow partial place name matches query["query"]["bool"]["must"].append({"wildcard": {"place.name.keyword": f"*{source}*"}}) elif source_type == "sensor": query["query"]["bool"]["must"].append( # Must be an exact match; otherwise "v1" would also match "v2", "v3", etc. # NOTE: In VA indices this is typically mapped as a keyword already. {"term": {"sensor.id": source}} ) return query @staticmethod def average_speed_per_direction_aggregation() -> dict: """ Aggregation for average speed per direction. Exactly matches web-api-core/queryTemplates/averageSpeedPerDirection.json Groups by direction and calculates avg speed for each direction. Returns: Aggregation specification for average speed per direction """ return { "directions": { "terms": {"field": "direction.keyword", "size": 100}, "aggs": {"averageSpeed": {"avg": {"field": "speed"}}}, } } ================================================ FILE: agent/src/vss_agents/video_analytics/tools.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from collections.abc import AsyncGenerator from copy import deepcopy import json from typing import Any from nat.builder.builder import Builder from nat.builder.framework_enum import LLMFrameworkEnum from nat.builder.function import FunctionGroup from nat.cli.register_workflow import register_function_group from nat.data_models.component_ref import FunctionRef from nat.data_models.function import FunctionGroupBaseConfig from pydantic import BaseModel from pydantic import Field from pydantic import field_validator from pydantic import model_validator from .es_client import BASE_QUERY_TEMPLATE from .es_client import ESClient from .query_builders import BehaviorQueryBuilder from .query_builders import FramesQueryBuilder from .query_builders import IncidentQueryBuilder from .utils import build_place_map from .utils import build_sensor_map from .utils import compute_bucket_size_seconds from .utils import create_empty_histogram_buckets from .utils import create_events_from_incidents from .utils import parse_vst_sensor_list_response from .utils import sweep_overlapping_incidents from .utils import validate_iso_timestamp # Input models for functions class EmptyInput(BaseModel): """Empty input for functions that take no parameters.""" pass class GetSensorIdsInput(BaseModel): """Input for get_sensor_ids function.""" place: str | None = Field(default=None, description="Optional place name to filter sensor IDs") class GetIncidentInput(BaseModel): """Input for get_incident function.""" id: str = Field(description="The incident ID to retrieve") includes: list[str] | None = Field(default=None, description="The metadata fields to include in the output") class GetIncidentsInputBase(BaseModel): """Base input for get_incidents function (without VLM verdict).""" source: str | None = Field( default=None, description="Optional source of the incidents (sensor ID or place/city name). If provided, source_type must also be provided. Place can be exact name or natural language description of place.", ) start_time: str | None = Field( default=None, description="Optional start time of query (ISO format: YYYY-MM-DDTHH:MM:SS.sssZ). If omitted, returns the most recent incidents up to max_count.", ) end_time: str | None = Field( default=None, description="Optional end time of query (ISO format: YYYY-MM-DDTHH:MM:SS.sssZ). If omitted, returns the most recent incidents up to max_count.", ) source_type: str | None = Field( default=None, description="The type of the source (must be 'sensor' or 'place'). 'place' uses wildcard matching and can match city names or intersection names. Required if source is provided.", ) max_count: int = Field(default=10, description="The maximum number of incidents to return") includes: list[str] | None = Field(default=None, description="The metadata fields to include in the output") @field_validator("start_time", "end_time") @classmethod def validate_timestamps(cls, v: str | None) -> str | None: """Validate timestamp format.""" if v is None: return None return validate_iso_timestamp(v) @field_validator("source_type") @classmethod def validate_source_type(cls, v: str | None) -> str | None: """Validate source_type is either 'sensor' or 'place'.""" if v is not None and v not in ["sensor", "place"]: raise ValueError(f"Video Analytics: source_type must be 'sensor' or 'place', got: '{v}'") return v @model_validator(mode="after") def validate_source_and_type_together(self) -> "GetIncidentsInputBase": """Validate that source and source_type are provided together.""" if (self.source is None) != (self.source_type is None): raise ValueError("Video Analytics: source and source_type must both be provided or both be omitted") return self @model_validator(mode="after") def validate_timestamps_together(self) -> "GetIncidentsInputBase": """Validate that start_time and end_time are provided together.""" if (self.start_time is None) != (self.end_time is None): raise ValueError("Video Analytics: start_time and end_time must both be provided or both be omitted") return self class GetIncidentsInputWithVLM(GetIncidentsInputBase): """Extended input for get_incidents function with VLM verdict support.""" vlm_verdict: str | None = Field( default=None, description="Optional VLM verdict filter (must be 'all', 'confirmed', 'rejected', 'verification-failed', or 'not-confirmed').", ) @field_validator("vlm_verdict") @classmethod def validate_vlm_verdict(cls, v: str | None) -> str | None: """Validate vlm_verdict is one of the allowed values.""" if v is not None: allowed_verdicts = ["all", "confirmed", "rejected", "verification-failed", "not-confirmed"] if v not in allowed_verdicts: raise ValueError(f"Video Analytics: vlm_verdict must be one of {allowed_verdicts}, got: '{v}'") return v class FovHistogramInput(BaseModel): """Input for get_fov_histogram function.""" source: str = Field(description="The source of the object counts (sensor ID)") start_time: str = Field(description="The start time of query (ISO format: YYYY-MM-DDTHH:MM:SS.sssZ)") end_time: str = Field(description="The end time of query (ISO format: YYYY-MM-DDTHH:MM:SS.sssZ)") object_type: str | None = Field(default=None, description="Optional type of the object to filter by") bucket_count: int = Field(default=10, description="Number of time buckets for histogram (default: 10)") @field_validator("start_time", "end_time") @classmethod def validate_timestamps(cls, v: str) -> str: """Validate timestamp format.""" return validate_iso_timestamp(v) class AverageSpeedsInput(BaseModel): """Input for get_average_speeds function.""" source: str = Field(description="The source of the query (sensor ID or place name)") start_time: str = Field(description="The start time of query (ISO format: YYYY-MM-DDTHH:MM:SS.sssZ)") end_time: str = Field(description="The end time of query (ISO format: YYYY-MM-DDTHH:MM:SS.sssZ)") source_type: str = Field(description="The type of the source (must be 'sensor' or 'place')") @field_validator("start_time", "end_time") @classmethod def validate_timestamps(cls, v: str) -> str: """Validate timestamp format.""" return validate_iso_timestamp(v) @field_validator("source_type") @classmethod def validate_source_type(cls, v: str) -> str: """Validate source_type is either 'sensor' or 'place'.""" if v not in ["sensor", "place"]: raise ValueError(f"Video Analytics: source_type must be 'sensor' or 'place', got: '{v}'") return v class AnalyzeInput(BaseModel): """Input for analyze function.""" start_time: str = Field(description="The start time of query (ISO format: YYYY-MM-DDTHH:MM:SS.sssZ)") end_time: str = Field(description="The end time of query (ISO format: YYYY-MM-DDTHH:MM:SS.sssZ)") source: str = Field(description="The source of the analysis (sensor ID or place name)") source_type: str = Field(description="The type of the source (must be 'sensor' or 'place')") analysis_type: str = Field( description=( "Type of analysis to perform (must be one of: 'max_min_incidents', 'average_speed', 'avg_num_people', 'avg_num_vehicles'):\n" "- max_min_incidents: Returns both minimum and maximum overlapping incidents\n" "- average_speed: Returns average speeds per direction\n" "- avg_num_people: Returns average number of people detected over time\n" "- avg_num_vehicles: Returns average number of vehicles detected over time" ) ) @field_validator("start_time", "end_time") @classmethod def validate_timestamps(cls, v: str) -> str: """Validate timestamp format.""" return validate_iso_timestamp(v) @field_validator("source_type") @classmethod def validate_source_type(cls, v: str) -> str: """Validate source_type is either 'sensor' or 'place'.""" if v not in ["sensor", "place"]: raise ValueError(f"Video Analytics: source_type must be 'sensor' or 'place', got: '{v}'") return v @field_validator("analysis_type") @classmethod def validate_analysis_type(cls, v: str) -> str: """Validate analysis_type is one of the allowed values.""" allowed_types = ["max_min_incidents", "average_speed", "avg_num_people", "avg_num_vehicles"] if v not in allowed_types: raise ValueError(f"Video Analytics: analysis_type must be one of {allowed_types}, got: '{v}'") return v class VideoAnalyticsToolConfig(FunctionGroupBaseConfig, name="video_analytics"): """Configuration for video analytics tools.""" es_url: str = Field(default="http://localhost:9200", description="Elasticsearch URL") index_prefix: str = Field(default="", description="Index prefix for all ES indexes") vlm_verified: bool = Field( default=False, description="If true, query VLM verified incidents index instead of regular incidents" ) vst_sensor_list_tool: FunctionRef | None = Field( default=None, description="Optional VST sensor list tool to filter active sensors" ) embedding_model_name: str | None = Field( default="sentence-transformers/all-MiniLM-L6-v2", description="Name of the sentence-transformers model to use for semantic place search. If provided, enables semantic search fallback when wildcard matching returns no results. (default: all-MiniLM-L6-v2, 384 dims)", ) include: list[str] = Field( default_factory=lambda: [ "get_incident", "get_incidents", "get_sensor_ids", "get_places", "get_fov_histogram", "get_average_speeds", "analyze", ], description="The list of functions to include in the video analytics function group.", ) @register_function_group(config_type=VideoAnalyticsToolConfig) async def video_analytics(_config: VideoAnalyticsToolConfig, _builder: Builder) -> AsyncGenerator[FunctionGroup]: """ Video analytics function group with ES integration. Mirrors the web-apis pattern where ES client is initialized once and shared across all tool functions. """ # Initialize shared ES client es_client = ESClient(_config.es_url, _config.index_prefix) group = FunctionGroup(config=_config) # Cache calibration data (fetch once and reuse) # This avoids repeated ES queries for place/sensor information cached_sensors = [] cached_sensor_map = {} cached_place_map = {} # Semantic search components embedding_model = None place_embedding_cache = None try: calibration_result = await es_client.get_by_id(index_key="calibration", doc_id="calibration") if calibration_result: calibration = calibration_result.get("calibration", {}) cached_sensors = calibration.get("sensors", []) # Pre-build both maps for efficient lookups cached_sensor_map = build_sensor_map(cached_sensors) cached_place_map = build_place_map(cached_sensors) # Generate embeddings for semantic place search if model is configured if _config.embedding_model_name: try: from .embeddings import EmbeddingModel from .embeddings import PlaceEmbeddingCache embedding_model = EmbeddingModel(_config.embedding_model_name) place_embedding_cache = PlaceEmbeddingCache() # Collect all place names before batch encoding # cached_place_map structure: {"city": ["intersection1", "intersection2", ...]} all_place_names = [] for city, intersections in cached_place_map.items(): if city: all_place_names.append(city) for intersection in intersections: if intersection: all_place_names.append(intersection) # Batch encode all places at once if all_place_names: all_embeddings = embedding_model.encode_batch(all_place_names) place_embedding_cache.add_places_batch(all_place_names, all_embeddings) except Exception: # Silently disable semantic search if initialization fails embedding_model = None place_embedding_cache = None except Exception: # Log error but continue - functions will return empty results pass async def _get_vst_sensor_names() -> set[str] | None: """ Fetch active sensor names from VST tool. Returns: Set of active sensor names, or None if VST is not configured or fails """ if not _config.vst_sensor_list_tool: return None try: sensor_list_tool = await _builder.get_tool( _config.vst_sensor_list_tool, wrapper_type=LLMFrameworkEnum.LANGCHAIN ) sensors_str = await sensor_list_tool.ainvoke(input={}) # Parse sensor list response using helper function result = parse_vst_sensor_list_response(sensors_str) return result except (json.JSONDecodeError, KeyError, ConnectionError, ValueError): return None except Exception: return None def _semantic_place_search(query: str) -> list[str]: """ Find places semantically similar to the query using cached embeddings. Uses hardcoded parameters: - threshold: 0.5 (minimum cosine similarity) - top_k: 5 (maximum number of matches) Args: query: The search query text Returns: List of place names that match semantically """ # Hardcoded parameters for semantic search semantic_threshold = 0.5 semantic_top_k = 3 # Check if semantic search is available if embedding_model is None or place_embedding_cache is None: return [] try: # Generate query embedding query_embedding = embedding_model.encode(query) # Find similar places results = place_embedding_cache.find_similar( query_embedding, top_k=semantic_top_k, threshold=semantic_threshold ) # Return just the place names return [place_name for place_name, _score in results] except Exception: return [] async def _get_incident(input: GetIncidentInput) -> dict: """ Get a specific incident by ID from the video analytics system. Returns the complete incident data including all available fields unless limited by the includes parameter. Args: input: Input parameters including incident id and optional includes Returns: dict: The incident data, or None if not found """ query = IncidentQueryBuilder.build_query_by_id(incident_id=input.id) # Choose index based on config vlm_verified setting index_key = "vlm_incidents" if _config.vlm_verified else "incidents" # Default fields to include incident_fields = ["Id", "id", "timestamp", "end", "sensorId"] # Add additional fields based on includes parameter if input.includes: for metadata in input.includes: incident_fields.append(metadata) # Query for single incident incidents = await es_client.search( index_key=index_key, query_body=query, size=1, source_includes=incident_fields ) # Return the incident if found, otherwise None return incidents[0] if incidents else {} async def _get_incidents(input: GetIncidentsInputBase) -> dict: """ Get incidents from the video analytics system. By default, only the most recent 10 incidents will be returned. Each incident only contains the incident id, start time, end time and sensor id unless additional fields are requested via includes. If source and source_type are omitted, all incidents will be queried (filtered by time range if provided). If start_time and end_time are omitted, returns the most recent incidents up to max_count. Args: input: Input parameters including optional source, optional source_type, optional start_time, optional end_time, max_count, and includes. If vlm_verified is enabled, also includes vlm_verdict. Returns: dict: A dictionary containing the list of incidents and a flag indicating if there are more incidents available """ # Get vlm_verdict if it exists on the input model (only present when vlm_verified=True) vlm_verdict = getattr(input, "vlm_verdict", None) # Build query using IncidentQueryBuilder # If source/source_type are None, query all incidents if input.source is not None and input.source_type is not None: query = IncidentQueryBuilder.build_query( source=input.source, source_type=input.source_type, start_time=input.start_time, end_time=input.end_time, vlm_verified=_config.vlm_verified, vlm_verdict=vlm_verdict, ) else: # Query all incidents without source filter query = IncidentQueryBuilder.build_query( source=None, source_type=None, start_time=input.start_time, end_time=input.end_time, vlm_verified=_config.vlm_verified, vlm_verdict=vlm_verdict, ) # Choose index based on config vlm_verified setting index_key = "vlm_incidents" if _config.vlm_verified else "incidents" # Fetch extra records to check if there are more fetch_size = input.max_count + 1 # Default fields to include incident_fields = ["Id", "id", "timestamp", "end", "sensorId"] # Add additional fields based on includes parameter if input.includes: for metadata in input.includes: incident_fields.append(metadata) # Sort by timestamp descending (most recent first) incidents = await es_client.search( index_key=index_key, query_body=query, size=fetch_size, sort="timestamp:desc", source_includes=incident_fields, ) # If no results and semantic search is available, try semantic fallback if ( len(incidents) == 0 and input.source is not None and input.source_type == "place" and embedding_model is not None and place_embedding_cache is not None ): # Find semantically similar places matched_places = _semantic_place_search(input.source) if matched_places: # Build new query with OR clause for all matched places # Use term queries for exact matching on the matched place names query = deepcopy(BASE_QUERY_TEMPLATE) # Add time range filters if provided if input.start_time is not None and input.end_time is not None: query["query"]["bool"]["must"].extend( [ {"range": {"timestamp": {"lte": input.end_time}}}, {"range": {"end": {"gte": input.start_time}}}, ] ) # Add should clause with all matched places (at least one must match) # Use wildcard matching since place names are stored as "city=X/intersection=Y" query["query"]["bool"]["should"] = [ {"wildcard": {"place.name.keyword": f"*{place_name}*"}} for place_name in matched_places ] query["query"]["bool"]["minimum_should_match"] = 1 # Add VLM verdict filter if applicable if _config.vlm_verified and vlm_verdict is not None: if vlm_verdict == "all": pass elif vlm_verdict == "not-confirmed": query["query"]["bool"]["must"].append( {"terms": {"info.verdict.keyword": ["rejected", "verification-failed"]}} ) else: query["query"]["bool"]["must"].append({"term": {"info.verdict.keyword": vlm_verdict}}) # Execute semantic search query incidents = await es_client.search( index_key=index_key, query_body=query, size=fetch_size, sort="timestamp:desc", source_includes=incident_fields, ) # Apply pagination paginated_incidents = incidents[0 : input.max_count] has_more = len(incidents) > input.max_count return {"incidents": paginated_incidents, "has_more": has_more} async def _get_sensor_ids(input: GetSensorIdsInput) -> list[str]: """ Get the list of sensor IDs from calibration configuration, optionally filtered by place. If VST sensor list is available, returns sensors that are either: - In calibration data AND in VST active list - In VST active list but NOT in calibration data (appended to the result) Args: input: Input parameters including optional place filter Returns: list[str]: List of sensor IDs """ # Use cached calibration data, or fetch on-demand if cache is empty sensors = cached_sensors sensor_map = cached_sensor_map if not sensors: # Fallback: fetch calibration data from ES if cache is empty calibration_result = await es_client.get_by_id(index_key="calibration", doc_id="calibration") if not calibration_result: # No calibration data, return VST sensors as list vst_sensors = await _get_vst_sensor_names() return list(vst_sensors) if vst_sensors else [] calibration = calibration_result.get("calibration", {}) sensors = calibration.get("sensors", []) sensor_map = build_sensor_map(sensors) # Get active sensors from VST (fetched on-demand each time) active_sensor_names = await _get_vst_sensor_names() # If place filter is specified, use sensor map if input.place: # Search all cities for the specified intersection (place) for _city, intersections in sensor_map.items(): if input.place in intersections: sensor_ids = intersections[input.place] # Filter by active sensors if available if active_sensor_names is not None: sensor_ids = [sid for sid in sensor_ids if sid in active_sensor_names] return sensor_ids return [] else: # Return all sensor IDs sensor_ids = [sensor.get("id") for sensor in sensors if "id" in sensor] # Filter by active sensors if available, then append any VST-only sensors if active_sensor_names is not None: # First, filter calibration sensors to those in VST active list filtered_ids = [sid for sid in sensor_ids if sid in active_sensor_names] # Then append any sensors from VST that aren't in calibration data calibration_sensor_ids = set(sensor_ids) vst_only_sensors = [sid for sid in active_sensor_names if sid not in calibration_sensor_ids] sensor_ids = filtered_ids + vst_only_sensors return sensor_ids async def _get_places(input: EmptyInput) -> dict: # noqa: ARG001 """ Get the hierarchical map of all available places. Returns the place_map structure: city -> [intersection] Args: input: Empty input (no parameters required) Returns: dict: Hierarchical place map with structure: { "city_name": ["intersection1", "intersection2", ...], ... } """ # Use cached place map, or fetch on-demand if cache is empty place_map = cached_place_map if not place_map: # Fallback: fetch calibration data from ES if cache is empty calibration_result = await es_client.get_by_id(index_key="calibration", doc_id="calibration") if not calibration_result: return {} calibration = calibration_result.get("calibration", {}) sensors = calibration.get("sensors", []) place_map = build_place_map(sensors) return place_map async def _get_fov_histogram(input: FovHistogramInput) -> dict: """ Returns FOV occupancy histogram with time buckets and object types. Uses frames index with nested fov field. Args: input: Input parameters including source, start_time, end_time, optional object_type, and bucket_count Returns: dict: Histogram with structure: { "bucketSizeInSec": 180, "histogram": [ { "start": "2023-01-12T11:20:10.000Z", "end": "2023-01-12T11:23:10.000Z", "objects": [ {"type": "Person", "averageCount": 5}, {"type": "Vehicle", "averageCount": 2} ] }, ... ] } """ # Compute bucket size bucket_size_sec = compute_bucket_size_seconds( start_time=input.start_time, end_time=input.end_time, bucket_count=input.bucket_count ) # Build query using FramesQueryBuilder (matches web-apis pattern) query = FramesQueryBuilder.build_query( sensor_id=input.source, start_time=input.start_time, end_time=input.end_time ) # Build FOV histogram aggregation aggs = FramesQueryBuilder.fov_histogram_aggregation( bucket_size_sec=bucket_size_sec, object_type=input.object_type ) # Execute aggregation on frames index results = await es_client.aggregate(index_key="frames", query_body=query, aggs=aggs) # Collect all object types seen across all buckets object_types = set() bucket_map = {} if results and "eventsOverTime" in results: for time_bucket in results["eventsOverTime"].get("buckets", []): start_time_str = time_bucket.get("key_as_string", time_bucket["key"]) # Parse start time and compute end time from datetime import datetime from datetime import timedelta start_dt = datetime.fromisoformat(start_time_str.replace("Z", "+00:00")) end_dt = start_dt + timedelta(seconds=bucket_size_sec) # Format with milliseconds explicitly (isoformat() omits .000 when microseconds are 0) start_str = start_dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" end_str = end_dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" objects_data: list[dict[str, Any]] = [] bucket_data: dict[str, Any] = {"start": start_str, "end": end_str, "objects": objects_data} # Navigate nested aggregation structure: fov.searchAggFilter.objectType.buckets # This matches the web-apis nested aggregation on fov field fov_agg = time_bucket.get("fov", {}) search_filter = fov_agg.get("searchAggFilter", {}) object_type_buckets = search_filter.get("objectType", {}).get("buckets", []) for obj_bucket in object_type_buckets: obj_type = obj_bucket["key"] avg_count = obj_bucket["avgCount"]["value"] objects_data.append({"type": obj_type, "averageCount": round(avg_count) if avg_count else 0}) object_types.add(obj_type) bucket_map[bucket_data["start"]] = bucket_data # Create empty histogram covering full time range empty_histogram = create_empty_histogram_buckets( start_time=input.start_time, end_time=input.end_time, bucket_size_sec=bucket_size_sec ) # Fill in data from bucket_map and ensure all object types appear in all buckets histogram: list[dict[str, Any]] = [] for empty_bucket in empty_histogram: if empty_bucket["start"] in bucket_map: # Use bucket with data bucket: dict[str, Any] = bucket_map[empty_bucket["start"]] else: # Use empty bucket bucket = empty_bucket # Ensure all object types are represented (with 0 if missing) objects_list: list[dict[str, Any]] = bucket["objects"] existing_types = {obj["type"] for obj in objects_list} for obj_type in object_types: if obj_type not in existing_types: objects_list.append({"type": obj_type, "averageCount": 0}) # Sort objects by type for consistency objects_list.sort(key=lambda x: x["type"]) histogram.append(bucket) return {"bucketSizeInSec": bucket_size_sec, "histogram": histogram} async def _get_average_speeds(input: AverageSpeedsInput) -> dict: """ Returns average speed per direction at source. Queries behavior index and groups by direction. Args: input: Input parameters including source, start_time, end_time, and source_type Returns: dict: Average speed metrics per direction { "metrics": [ {"direction": "North", "averageSpeed": "25 mph"}, {"direction": "South", "averageSpeed": "30 mph"} ] } """ # Build query exactly matching web-apis (lines 109-126 in Behavior.js) query = BehaviorQueryBuilder.build_average_speed_query( source=input.source, source_type=input.source_type, start_time=input.start_time, end_time=input.end_time ) # Build aggregation matching averageSpeedPerDirection.json aggs = BehaviorQueryBuilder.average_speed_per_direction_aggregation() # Execute aggregation results = await es_client.aggregate(index_key="behavior", query_body=query, aggs=aggs) # Format results matching web-apis output (lines 130-143 in Behavior.js) metrics = [] if results and "directions" in results: for direction_bucket in results["directions"].get("buckets", []): direction = direction_bucket["key"] avg_speed_value = direction_bucket.get("averageSpeed", {}).get("value") # Format speed with unit (web-apis uses mph for cartesian, assuming mph here) # In web-apis line 290: result.averageSpeed = `${Math.floor(result.avgSpeedDetails.averageSpeed)} ${averageSpeedUnit}`; if avg_speed_value is not None: speed_str = f"{int(avg_speed_value)} mph" else: speed_str = "0 mph" metrics.append({"direction": direction, "averageSpeed": speed_str}) return {"metrics": metrics} async def _analyze(input: AnalyzeInput) -> str: """ Analyze the incidents in the video analytics system. Args: input: Input parameters including start_time, end_time, source, source_type, and analysis_type Returns: str: The analysis result in natural language """ if input.analysis_type == "max_min_incidents": # Build query for incidents query = IncidentQueryBuilder.build_query( source=input.source, source_type=input.source_type, start_time=input.start_time, end_time=input.end_time ) # Choose index based on config vlm_verified setting index_key = "vlm_incidents" if _config.vlm_verified else "incidents" # Limit analysis to most recent 1000 incidents # Fetch +1 to detect if there are more incidents max_incidents_to_analyze = 1000 fetch_size = max_incidents_to_analyze + 1 # Fetch incidents with timestamp and end times, sorted by most recent first all_incidents = await es_client.search( index_key=index_key, query_body=query, size=fetch_size, sort="timestamp:desc", source_includes=["timestamp", "end"], ) if not all_incidents: return f"Between {input.start_time} and {input.end_time}, there were no incidents at {input.source}." # Check if there are more incidents beyond our analysis window has_more = len(all_incidents) > max_incidents_to_analyze incidents = all_incidents[:max_incidents_to_analyze] # Use utility function to convert incidents to events events, valid_incident_count = create_events_from_incidents(incidents) if valid_incident_count == 0: return f"Between {input.start_time} and {input.end_time}, there were no valid incidents with timestamps at {input.source}." # Sweep through events to find BOTH minimum and maximum overlapping counts in single pass max_count, max_time, min_count, min_time = sweep_overlapping_incidents(events) # Format response with both min and max more_msg = f" (analyzed most recent {valid_incident_count} incidents; more exist)" if has_more else "" # Build comprehensive response result_parts = [ f"Between {input.start_time} and {input.end_time}, there were a total of {valid_incident_count} incidents analyzed at {input.source}{more_msg}." ] # Add maximum overlap information max_time_str = max_time.strftime("%Y-%m-%d %H:%M:%S") if max_time else "during the period" result_parts.append(f"Maximum overlap: {max_count} incident(s) at {max_time_str}.") # Add minimum overlap information min_time_str = min_time.strftime("%Y-%m-%d %H:%M:%S") if min_time else "during the period" result_parts.append(f"Minimum overlap: {min_count} incident(s) at {min_time_str}.") return " ".join(result_parts) elif input.analysis_type == "average_speed": # Get average speed per direction result = await _get_average_speeds( AverageSpeedsInput( source=input.source, start_time=input.start_time, end_time=input.end_time, source_type=input.source_type, ) ) metrics = result.get("metrics", []) if metrics: speed_summary = ", ".join([f"{m['direction']}: {m['averageSpeed']}" for m in metrics]) return ( f"Average speeds at {input.source} between {input.start_time} and {input.end_time}: {speed_summary}" ) else: return f"No speed data available at {input.source} between {input.start_time} and {input.end_time}." elif input.analysis_type == "avg_num_people": # Get average number of people over the time period result = await _get_fov_histogram( FovHistogramInput( source=input.source, start_time=input.start_time, end_time=input.end_time, object_type="Person" ) ) # Extract objects from histogram buckets histogram = result.get("histogram", []) person_counts = [] for bucket in histogram: for obj in bucket.get("objects", []): if obj["type"] == "Person": person_counts.append(obj["averageCount"]) if person_counts: overall_average = sum(person_counts) / len(person_counts) return f"The average number of people at {input.source} between {input.start_time} and {input.end_time} was {overall_average:.2f}." return f"No people detected at {input.source} between {input.start_time} and {input.end_time}." elif input.analysis_type == "avg_num_vehicles": # Get average number of vehicles over the time period result = await _get_fov_histogram( FovHistogramInput( source=input.source, start_time=input.start_time, end_time=input.end_time, object_type="Vehicle" ) ) # Extract objects from histogram buckets histogram = result.get("histogram", []) vehicle_counts = [] for bucket in histogram: for obj in bucket.get("objects", []): if obj["type"] == "Vehicle": vehicle_counts.append(obj["averageCount"]) if vehicle_counts: overall_average = sum(vehicle_counts) / len(vehicle_counts) return f"The average number of vehicles at {input.source} between {input.start_time} and {input.end_time} was {overall_average:.2f}." return f"No vehicles detected at {input.source} between {input.start_time} and {input.end_time}." return f"Unknown analysis type: {input.analysis_type}" # Register functions based on config if "get_incident" in _config.include: group.add_function(name="get_incident", fn=_get_incident, description=_get_incident.__doc__) if "get_incidents" in _config.include: # When vlm_verified=True, include vlm_verdict parameter otherwise, exclude vlm_verdict parameter completely if _config.vlm_verified: async def _get_incidents_vlm(input: GetIncidentsInputWithVLM) -> dict: return await _get_incidents(input) group.add_function(name="get_incidents", fn=_get_incidents_vlm, description=_get_incidents.__doc__) else: async def _get_incidents_base(input: GetIncidentsInputBase) -> dict: return await _get_incidents(input) group.add_function(name="get_incidents", fn=_get_incidents_base, description=_get_incidents.__doc__) if "get_sensor_ids" in _config.include: group.add_function(name="get_sensor_ids", fn=_get_sensor_ids, description=_get_sensor_ids.__doc__) if "get_places" in _config.include: group.add_function(name="get_places", fn=_get_places, description=_get_places.__doc__) if "get_fov_histogram" in _config.include: group.add_function(name="get_fov_histogram", fn=_get_fov_histogram, description=_get_fov_histogram.__doc__) if "get_average_speeds" in _config.include: group.add_function(name="get_average_speeds", fn=_get_average_speeds, description=_get_average_speeds.__doc__) if "analyze" in _config.include: group.add_function(name="analyze", fn=_analyze, description=_analyze.__doc__) yield group ================================================ FILE: agent/src/vss_agents/video_analytics/utils.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Utility functions for video analytics tools.""" from datetime import UTC from datetime import datetime from datetime import timedelta import json import logging import re from typing import Any logger = logging.getLogger(__name__) def validate_iso_timestamp(timestamp: str) -> str: """ Validate ISO 8601 timestamp format with milliseconds and Z timezone. Expected format: YYYY-MM-DDTHH:MM:SS.sssZ Example: 2022-08-25T00:00:10.000Z Args: timestamp: The timestamp string to validate Returns: str: The validated timestamp string Raises: ValueError: If timestamp format is invalid """ # ISO 8601 pattern with milliseconds and Z timezone iso_pattern = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$" if not re.match(iso_pattern, timestamp): raise ValueError( f"Video Analytics: Invalid timestamp format: '{timestamp}'. " f"Expected ISO 8601 format with milliseconds: YYYY-MM-DDTHH:MM:SS.sssZ " f"(e.g., 2022-08-25T00:00:10.000Z)" ) # Validate that it can be parsed as a valid datetime try: datetime.fromisoformat(timestamp.replace("Z", "+00:00")) except ValueError as e: raise ValueError(f"Video Analytics: Invalid datetime values in timestamp '{timestamp}': {e}") from e return timestamp def build_sensor_map(sensors: list[dict[str, Any]]) -> dict[str, dict[str, list[str]]]: """ Build a hierarchical map of places to sensor IDs. Creates a structure: city -> intersection -> [sensor_ids] Args: sensors: List of sensor objects from calibration configuration. Each sensor should have 'id' and 'place' fields. Returns: Dict mapping city -> intersection -> list of sensor IDs Example: { "San Jose": { "Intersection_A": ["sensor-1", "sensor-2"], "Intersection_B": ["sensor-3"] }, "Mountain View": { "Intersection_C": ["sensor-4", "sensor-5"] } } """ place_map: dict[str, dict[str, list[str]]] = {} for sensor in sensors: # Validate sensor has required structure if "place" not in sensor or not isinstance(sensor["place"], list) or len(sensor["place"]) < 2: logger.warning(f"Skipping sensor due to missing or malformed 'place': {sensor}") continue # Extract city and intersection values city = sensor["place"][0].get("value") intersection = sensor["place"][1].get("value") if city is None or intersection is None: logger.warning(f"Sensor missing city or intersection value: {sensor}") continue # Initialize nested dictionaries if needed if city not in place_map: place_map[city] = {} if intersection not in place_map[city]: place_map[city][intersection] = [] # Add sensor ID if present if "id" in sensor: sensor_id = sensor["id"] place_map[city][intersection].append(sensor_id) else: logger.warning(f"Skipping sensor with malformed place due to missing 'id': {sensor}") return place_map def build_place_map(sensors: list[dict[str, Any]]) -> dict[str, list[str]]: """ Build a map from city name to a list of intersections (no sensor id information). Creates a structure: city -> [intersection1, intersection2, ...] Args: sensors: List of sensor objects from calibration configuration. Each sensor should have 'place' field as list of at least two dicts (city, intersection). Returns: Dict mapping city -> list of intersection names Example: { "San Jose": ["Intersection_A", "Intersection_B"], "Mountain View": ["Intersection_C"] } """ city_map: dict[str, set[str]] = {} for sensor in sensors: # Validate sensor has required structure if "place" not in sensor or not isinstance(sensor["place"], list) or len(sensor["place"]) < 2: logger.warning(f"Skipping sensor due to missing or malformed 'place': {sensor}") continue city = sensor["place"][0].get("value") intersection = sensor["place"][1].get("value") if city is None or intersection is None: logger.warning(f"Sensor missing city or intersection value: {sensor}") continue if city not in city_map: city_map[city] = set() city_map[city].add(intersection) # Convert sets to sorted lists for consistency city_map_lists: dict[str, list[str]] = {city: sorted(intersections) for city, intersections in city_map.items()} return city_map_lists def parse_vst_sensor_list_response(sensors_str: str) -> set[str]: """ Parse VST sensor list response string into a set of sensor names. Supports: - VSTSensorListOutput format: {"sensor_names": ["name1", "name2", ...]} - Legacy format: {"sensor_id": {"name": "...", "sensorId": "...", ...}, ...} Args: sensors_str: String response from VST sensor list tool Returns: Set of sensor names extracted from the response (always set[str], empty on parse failure). """ text = (sensors_str or "").strip() # Trim surrounding quotes if present (e.g., "..." or '...') if text and text[0] == text[-1] and text[0] in ('"', "'"): text = text[1:-1] if not text: return set() try: decoded = json.loads(text) except json.JSONDecodeError as e: logger.debug(f"Failed to parse VST sensor list response: {e}") return set() result: set[str] = set() if isinstance(decoded, dict): if "sensor_names" in decoded and isinstance(decoded["sensor_names"], list): for item in decoded["sensor_names"]: if isinstance(item, str): result.add(item) else: # Fallback: {"sensor_id": {"name": ...}, ...} for value in decoded.values(): if isinstance(value, dict) and "name" in value: name = value["name"] if isinstance(name, str): result.add(name) return result def compute_bucket_size_seconds(start_time: str, end_time: str, bucket_count: int) -> int: """ Compute bucket size in seconds for histogram. Args: start_time: Start timestamp in ISO format end_time: End timestamp in ISO format bucket_count: Number of buckets desired Returns: Bucket size in seconds """ if bucket_count <= 0: raise ValueError(f"Video Analytics: bucket_count must be a positive integer, got {bucket_count}") start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00")) time_range_seconds = (end_dt - start_dt).total_seconds() bucket_size_seconds = int(time_range_seconds / bucket_count) # Ensure at least 1 second bucket size return max(1, bucket_size_seconds) def create_empty_histogram_buckets(start_time: str, end_time: str, bucket_size_sec: int) -> list[dict[str, Any]]: """ Create empty histogram buckets covering the time range. Args: start_time: Start timestamp in ISO format end_time: End timestamp in ISO format bucket_size_sec: Size of each bucket in seconds Returns: List of empty histogram buckets with start and end times """ if bucket_size_sec <= 0: raise ValueError(f"Video Analytics: bucket_size_sec must be a positive integer, got {bucket_size_sec}") start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00")) # Align start_dt to bucket boundary (floor to bucket_size_sec) epoch_seconds = int(start_dt.timestamp()) aligned_epoch = (epoch_seconds // bucket_size_sec) * bucket_size_sec current_start = datetime.fromtimestamp(aligned_epoch, tz=UTC) buckets = [] while current_start < end_dt: current_end = current_start + timedelta(seconds=bucket_size_sec) if current_end > end_dt: current_end = end_dt # Format with milliseconds explicitly (isoformat() omits .000 when microseconds are 0) start_str = current_start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" # Convert microseconds to milliseconds end_str = current_end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" buckets.append({"start": start_str, "end": end_str, "objects": []}) current_start = current_end return buckets def create_events_from_incidents(incidents: list[dict[str, Any]]) -> tuple[list[tuple[datetime, int]], int]: """ Convert incident list to events for overlap analysis using sweep line algorithm. Args: incidents: List of incident dictionaries with 'timestamp' and 'end' fields Returns: tuple: (sorted events list, valid_incident_count) - events: List of (datetime, delta) tuples where delta is +1 for start, -1 for end - valid_incident_count: Number of incidents with valid timestamps """ events = [] valid_incident_count = 0 for incident in incidents: start_time_str = incident.get("timestamp") end_time_str = incident.get("end") if start_time_str and end_time_str: # Parse timestamps start = datetime.fromisoformat(start_time_str.replace("Z", "+00:00")) end = datetime.fromisoformat(end_time_str.replace("Z", "+00:00")) # Add start event (+1) and end event (-1) events.append((start, 1)) # Incident starts events.append((end, -1)) # Incident ends valid_incident_count += 1 # Sort events by time, with start events (+1) before end events (-1) at same time events.sort(key=lambda x: (x[0], -x[1])) return events, valid_incident_count def sweep_overlapping_incidents( events: list[tuple[datetime, int]], ) -> tuple[int, datetime | None, int, datetime | None]: """ Sweep through events to find min and max overlapping counts. Uses sweep line algorithm to efficiently find both minimum and maximum overlapping incidents in a single pass. Args: events: Sorted list of (time, delta) tuples where delta is +1 for start, -1 for end Returns: tuple: (max_count, max_time, min_count, min_time) - max_count: Maximum number of overlapping incidents - max_time: Time when maximum overlap occurred - min_count: Minimum number of overlapping incidents - min_time: Time when minimum overlap occurred """ current_count = 0 max_count = 0 max_time: datetime | None = None min_count: int | float = float("inf") min_time: datetime | None = None for time, delta in events: current_count += delta if current_count > max_count: max_count = current_count max_time = time if current_count < min_count: min_count = current_count min_time = time # Convert min_count to int (will be inf if no events, convert to 0) final_min_count = 0 if min_count == float("inf") else int(min_count) return max_count, max_time, final_min_count, min_time ================================================ FILE: agent/stubs/nat/__init__.pyi ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/stubs/nat/data_models/__init__.pyi ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from .evaluator import EvaluatorBaseConfig as EvaluatorBaseConfig from .function import FunctionBaseConfig as FunctionBaseConfig from .function import FunctionGroupBaseConfig as FunctionGroupBaseConfig ================================================ FILE: agent/stubs/nat/data_models/common.pyi ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from pydantic import BaseModel class BaseModelRegistryTag: ... class TypedBaseModel(BaseModel): def __init_subclass__(cls, name: str | None = None, **kwargs: object) -> None: ... ================================================ FILE: agent/stubs/nat/data_models/evaluator.pyi ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from .common import BaseModelRegistryTag from .common import TypedBaseModel class EvaluatorBaseConfig(TypedBaseModel, BaseModelRegistryTag): ... ================================================ FILE: agent/stubs/nat/data_models/function.pyi ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from .common import BaseModelRegistryTag from .common import TypedBaseModel class FunctionBaseConfig(TypedBaseModel, BaseModelRegistryTag): ... class FunctionGroupBaseConfig(TypedBaseModel, BaseModelRegistryTag): ... ================================================ FILE: agent/tests/unit_test/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vss_agents package.""" ================================================ FILE: agent/tests/unit_test/agents/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vss_agents.agents package.""" ================================================ FILE: agent/tests/unit_test/agents/postprocessing/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/tests/unit_test/agents/postprocessing/test_llm_based_rule_validator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for LLMBasedRuleValidator.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch from langchain_core.exceptions import OutputParserException import pytest from vss_agents.agents.postprocessing.validators.llm_based_rule_validator import LLMBasedRuleValidator from vss_agents.agents.postprocessing.validators.llm_based_rule_validator import LLMBasedRuleValidatorOutput @pytest.fixture def mock_llm(): """Create a mock LLM that returns structured output.""" llm = MagicMock() llm.model_name = "test-model" # with_structured_output returns another mock that has ainvoke structured = AsyncMock() llm.with_structured_output = MagicMock(return_value=structured) return llm class TestLLMBasedRuleValidatorInit: """Tests for LLMBasedRuleValidator initialization.""" def test_custom_prompt_template(self, mock_llm): v = LLMBasedRuleValidator(llm=mock_llm, prompt_template="Custom: {output} {user_query} {trajectory}") assert v.prompt_template == "Custom: {output} {user_query} {trajectory}" def test_negative_max_retries_raises(self, mock_llm): with pytest.raises(ValueError, match="max_retries must be >= 0"): LLMBasedRuleValidator(llm=mock_llm, max_retries=-1) class TestLLMBasedRuleValidatorValidate: """Tests for LLMBasedRuleValidator.validate().""" @pytest.mark.asyncio async def test_passes_when_llm_says_passed(self, mock_llm): llm_output = LLMBasedRuleValidatorOutput(passed=True, feedback="") mock_llm.with_structured_output.return_value.ainvoke = AsyncMock(return_value=llm_output) with ( patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_thinking_tag", return_value="" ), patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_llm_reasoning_bind_kwargs", return_value={}, ), ): v = LLMBasedRuleValidator(llm=mock_llm) result = await v.validate("good output", user_query="test query") assert result.passed is True assert result.issues == [] @pytest.mark.asyncio async def test_fails_when_llm_says_failed(self, mock_llm): llm_output = LLMBasedRuleValidatorOutput(passed=False, feedback="needs improvement") mock_llm.with_structured_output.return_value.ainvoke = AsyncMock(return_value=llm_output) with ( patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_thinking_tag", return_value="" ), patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_llm_reasoning_bind_kwargs", return_value={}, ), ): v = LLMBasedRuleValidator(llm=mock_llm) result = await v.validate("bad output", user_query="test query") assert result.passed is False assert "needs improvement" in result.issues @pytest.mark.asyncio async def test_retries_on_output_parser_exception(self, mock_llm): """Should retry on OutputParserException, then succeed.""" llm_output = LLMBasedRuleValidatorOutput(passed=True, feedback="") structured = mock_llm.with_structured_output.return_value structured.ainvoke = AsyncMock(side_effect=[OutputParserException("parse error"), llm_output]) with ( patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_thinking_tag", return_value="" ), patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_llm_reasoning_bind_kwargs", return_value={}, ), ): v = LLMBasedRuleValidator(llm=mock_llm, max_retries=1) result = await v.validate("output", user_query="test") assert result.passed is True assert structured.ainvoke.call_count == 2 @pytest.mark.asyncio async def test_raises_after_all_retries_exhausted(self, mock_llm): """Should raise the last exception after retries are exhausted.""" structured = mock_llm.with_structured_output.return_value structured.ainvoke = AsyncMock(side_effect=OutputParserException("parse error")) with ( patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_thinking_tag", return_value="" ), patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_llm_reasoning_bind_kwargs", return_value={}, ), ): v = LLMBasedRuleValidator(llm=mock_llm, max_retries=1) with pytest.raises(OutputParserException): await v.validate("output", user_query="test") assert structured.ainvoke.call_count == 2 # 1 initial + 1 retry @pytest.mark.asyncio async def test_unexpected_exception_breaks_retry_loop(self, mock_llm): """Unexpected exceptions should break the retry loop immediately.""" structured = mock_llm.with_structured_output.return_value structured.ainvoke = AsyncMock(side_effect=RuntimeError("unexpected")) with ( patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_thinking_tag", return_value="" ), patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_llm_reasoning_bind_kwargs", return_value={}, ), ): v = LLMBasedRuleValidator(llm=mock_llm, max_retries=3) with pytest.raises(RuntimeError): await v.validate("output", user_query="test") # Should have broken out after 1 attempt, not retried 3 times assert structured.ainvoke.call_count == 1 @pytest.mark.asyncio async def test_bad_prompt_template_falls_back_to_default(self, mock_llm): """Bad prompt template should fall back to DEFAULT_PROMPT_TEMPLATE.""" llm_output = LLMBasedRuleValidatorOutput(passed=True, feedback="") mock_llm.with_structured_output.return_value.ainvoke = AsyncMock(return_value=llm_output) with ( patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_thinking_tag", return_value="" ), patch( "vss_agents.agents.postprocessing.validators.llm_based_rule_validator.get_llm_reasoning_bind_kwargs", return_value={}, ), ): v = LLMBasedRuleValidator(llm=mock_llm, prompt_template="Bad template: {missing_key}") result = await v.validate("output", user_query="test") assert result.passed is True ================================================ FILE: agent/tests/unit_test/agents/postprocessing/test_non_empty_response_validator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for NonEmptyResponseValidator.""" import pytest from vss_agents.agents.postprocessing.validators.non_empty_response_validator import NonEmptyResponseValidator @pytest.fixture def validator(): return NonEmptyResponseValidator() @pytest.fixture def validator_with_template(): return NonEmptyResponseValidator(feedback_template="Issue: {issues}") class TestNonEmptyResponseValidator: """Tests for NonEmptyResponseValidator.""" @pytest.mark.asyncio async def test_passes_on_non_empty_output(self, validator): result = await validator.validate("Hello world") assert result.passed is True assert result.issues == [] @pytest.mark.asyncio async def test_fails_on_empty_string(self, validator): result = await validator.validate("") assert result.passed is False assert "Response is empty" in result.issues @pytest.mark.asyncio async def test_fails_on_whitespace_only(self, validator): result = await validator.validate(" \n\t ") assert result.passed is False assert "Response is empty" in result.issues def test_feedback_template(self, validator_with_template): feedback = validator_with_template.format_feedback(["Response is empty"]) assert "Issue:" in feedback def test_feedback_template_none_normalized(self): v = NonEmptyResponseValidator(feedback_template=None) assert v.feedback_template == "" ================================================ FILE: agent/tests/unit_test/agents/postprocessing/test_postprocessing_node.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for PostprocessingNode.""" from unittest.mock import AsyncMock import pytest from vss_agents.agents.postprocessing.data_models import NonEmptyResponseValidatorConfig from vss_agents.agents.postprocessing.data_models import PostprocessingConfig from vss_agents.agents.postprocessing.data_models import URLValidatorConfig from vss_agents.agents.postprocessing.data_models import ValidatorResult from vss_agents.agents.postprocessing.data_models import ValidatorsConfig from vss_agents.agents.postprocessing.postprocessing_node import PostprocessingNode class TestPostprocessingNodeInit: """Tests for PostprocessingNode initialization.""" def test_default_config(self): node = PostprocessingNode() assert node.config.enabled is True assert node.validators_by_name == {} assert node.validation_order == [] def test_creates_non_empty_validator(self): config = PostprocessingConfig( validators=ValidatorsConfig(non_empty_response_validator=NonEmptyResponseValidatorConfig()) ) node = PostprocessingNode(config=config) assert "non_empty_response_validator" in node.validators_by_name def test_creates_url_validator(self): config = PostprocessingConfig( validators=ValidatorsConfig(url_validator=URLValidatorConfig(internal_ip="127.0.0.1")) ) node = PostprocessingNode(config=config) assert "url_validator" in node.validators_by_name def test_custom_validation_order(self): config = PostprocessingConfig( validators=ValidatorsConfig( url_validator=URLValidatorConfig(internal_ip="127.0.0.1"), non_empty_response_validator=NonEmptyResponseValidatorConfig(), ), validation_order=[["non_empty_response_validator", "url_validator"]], ) node = PostprocessingNode(config=config) assert node.validation_order == [["non_empty_response_validator", "url_validator"]] def test_default_validation_order_is_sequential(self): config = PostprocessingConfig( validators=ValidatorsConfig( url_validator=URLValidatorConfig(internal_ip="127.0.0.1"), non_empty_response_validator=NonEmptyResponseValidatorConfig(), ), ) node = PostprocessingNode(config=config) # Each validator in its own group for group in node.validation_order: assert len(group) == 1 class TestPostprocessingNodeProcess: """Tests for PostprocessingNode.process().""" @pytest.mark.asyncio async def test_empty_output_passes_without_non_empty_validator(self): config = PostprocessingConfig( validators=ValidatorsConfig(url_validator=URLValidatorConfig(internal_ip="127.0.0.1")) ) node = PostprocessingNode(config=config) result = await node.process("") assert result.passed is True @pytest.mark.asyncio async def test_empty_output_fails_with_non_empty_validator(self): config = PostprocessingConfig( validators=ValidatorsConfig(non_empty_response_validator=NonEmptyResponseValidatorConfig()) ) node = PostprocessingNode(config=config) result = await node.process("") assert result.passed is False @pytest.mark.asyncio async def test_non_empty_output_passes_non_empty_validator(self): config = PostprocessingConfig( validators=ValidatorsConfig(non_empty_response_validator=NonEmptyResponseValidatorConfig()) ) node = PostprocessingNode(config=config) result = await node.process("Hello world") assert result.passed is True @pytest.mark.asyncio async def test_no_validators_passes(self): node = PostprocessingNode() result = await node.process("anything") assert result.passed is True class TestPostprocessingNodeFailOpen: """Tests for fail-open / fail-closed behavior.""" @pytest.mark.asyncio async def test_fail_open_on_validator_exception(self): config = PostprocessingConfig( validators=ValidatorsConfig(non_empty_response_validator=NonEmptyResponseValidatorConfig()), fail_open_on_validator_error=True, ) node = PostprocessingNode(config=config) # Make the validator raise validator = node.validators_by_name["non_empty_response_validator"] validator.validate = AsyncMock(side_effect=RuntimeError("boom")) result = await node.process("test output") assert result.passed is True # fail-open @pytest.mark.asyncio async def test_fail_closed_on_validator_exception(self): config = PostprocessingConfig( validators=ValidatorsConfig(non_empty_response_validator=NonEmptyResponseValidatorConfig()), fail_open_on_validator_error=False, ) node = PostprocessingNode(config=config) validator = node.validators_by_name["non_empty_response_validator"] validator.validate = AsyncMock(side_effect=RuntimeError("boom")) result = await node.process("test output") assert result.passed is False assert "VALIDATION ERROR" in result.feedback class TestPostprocessingNodeGroupTimeout: """Tests for group timeout behavior.""" @pytest.mark.asyncio async def test_group_timeout_fail_open(self): config = PostprocessingConfig( validators=ValidatorsConfig(non_empty_response_validator=NonEmptyResponseValidatorConfig()), group_timeout_seconds=0.001, # very short timeout fail_open_on_validator_error=True, ) node = PostprocessingNode(config=config) # Make validator hang async def slow_validate(**kwargs): import asyncio await asyncio.sleep(10) return ValidatorResult(name="test", passed=True) validator = node.validators_by_name["non_empty_response_validator"] validator.validate = slow_validate result = await node.process("test output") assert result.passed is True # fail-open on timeout @pytest.mark.asyncio async def test_group_timeout_fail_closed(self): config = PostprocessingConfig( validators=ValidatorsConfig(non_empty_response_validator=NonEmptyResponseValidatorConfig()), group_timeout_seconds=0.001, fail_open_on_validator_error=False, ) node = PostprocessingNode(config=config) async def slow_validate(**kwargs): import asyncio await asyncio.sleep(10) return ValidatorResult(name="test", passed=True) validator = node.validators_by_name["non_empty_response_validator"] validator.validate = slow_validate result = await node.process("test output") assert result.passed is False assert "TIMEOUT" in result.feedback ================================================ FILE: agent/tests/unit_test/agents/postprocessing/test_url_validator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for URLValidator.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.agents.postprocessing.validators.url_validator import URLValidator from vss_agents.agents.postprocessing.validators.url_validator import extract_urls from vss_agents.agents.postprocessing.validators.url_validator import extract_urls_from_tags_with_alt class TestExtractUrls: """Tests for the extract_urls helper.""" def test_extracts_http_urls(self): text = "Visit http://example.com for more info." assert extract_urls(text) == ["http://example.com"] def test_extracts_https_urls(self): text = "See https://example.com/page?q=1" assert extract_urls(text) == ["https://example.com/page?q=1"] def test_deduplicates(self): text = "http://example.com and http://example.com again" assert extract_urls(text) == ["http://example.com"] def test_strips_trailing_punctuation(self): text = "Check http://example.com. Also http://other.com," urls = extract_urls(text) assert "http://example.com" in urls assert "http://other.com" in urls def test_no_urls(self): assert extract_urls("No URLs here") == [] def test_multiple_urls(self): text = "First http://a.com then https://b.com/path" urls = extract_urls(text) assert len(urls) == 2 assert urls[0] == "http://a.com" assert urls[1] == "https://b.com/path" def test_ignores_non_http_schemes(self): text = "ftp://files.example.com and rtsp://stream.example.com" assert extract_urls(text) == [] def _mock_response(status): """Create a mock aiohttp response with the given status code.""" resp = AsyncMock() resp.status = status resp.__aenter__ = AsyncMock(return_value=resp) resp.__aexit__ = AsyncMock(return_value=False) return resp class TestURLValidator: """Tests for URLValidator.""" @pytest.fixture def validator(self): return URLValidator(internal_ip="127.0.0.1", timeout=5.0, max_retries=0) @pytest.mark.asyncio async def test_passes_when_no_urls(self, validator): """Text with no tags-with-alt, no markdown links, no plain http(s) URLs passes.""" result = await validator.validate("No links here") assert result.passed is True assert result.issues == [] @pytest.mark.asyncio async def test_fails_when_tag_src_is_placeholder(self, validator): """Tag with alt and invalid src (e.g. placeholder) fails; issues contain the URL only.""" result = await validator.validate('placeholder_alt') assert result.passed is False assert result.issues == ["placeholder_url"] @pytest.mark.asyncio async def test_fails_when_markdown_link_url_is_placeholder(self, validator): """Markdown link with invalid URL fails; issues contain the URL only.""" result = await validator.validate("See [text](placeholder_url) for details.") assert result.passed is False assert result.issues == ["placeholder_url"] @pytest.mark.asyncio async def test_passes_when_tag_src_url_returns_200(self, validator): """Tag with alt and valid http URL that is accessible passes.""" mock_resp = _mock_response(200) mock_session = AsyncMock() mock_session.head = MagicMock(return_value=mock_resp) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=mock_session): result = await validator.validate('caption') assert result.passed is True assert result.issues == [] @pytest.mark.asyncio async def test_fails_when_tag_src_url_returns_500(self, validator): """Tag with alt and valid URL that returns 500 fails; issues contain the URL only.""" mock_resp = _mock_response(500) mock_session = AsyncMock() mock_session.head = MagicMock(return_value=mock_resp) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=mock_session): result = await validator.validate('caption') assert result.passed is False assert result.issues == ["http://example.com/path"] @pytest.mark.asyncio async def test_head_405_falls_back_to_get(self, validator): """When HEAD returns 405, should fall back to GET.""" head_resp = _mock_response(405) get_resp = _mock_response(200) mock_session = AsyncMock() mock_session.head = MagicMock(return_value=head_resp) mock_session.get = MagicMock(return_value=get_resp) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=mock_session): result = await validator.validate('caption') assert result.passed is True @pytest.mark.asyncio async def test_head_exception_falls_back_to_get(self, validator): """When HEAD raises an exception, should fall back to GET.""" get_resp = _mock_response(200) mock_session = AsyncMock() mock_session.head = MagicMock(side_effect=Exception("connection refused")) mock_session.get = MagicMock(return_value=get_resp) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=mock_session): result = await validator.validate('caption') assert result.passed is True @pytest.mark.asyncio async def test_returns_all_invalid_and_inaccessible_urls_at_once(self, validator): """One invalid (placeholder) and one inaccessible URL: issues contain both.""" bad_resp = _mock_response(500) mock_session = AsyncMock() mock_session.head = MagicMock(return_value=bad_resp) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=mock_session): result = await validator.validate( 'a Also see link' ) assert result.passed is False assert "placeholder_url" in result.issues assert "http://bad.com/page" in result.issues assert len(result.issues) == 2 @pytest.mark.asyncio async def test_multiple_tags_partial_failure(self, validator): """One accessible and one inaccessible URL in tags with alt: issues contain only the failed URL.""" good_resp = _mock_response(200) bad_resp = _mock_response(500) call_count = 0 def make_head_response(*args, **kwargs): nonlocal call_count call_count += 1 return good_resp if call_count == 1 else bad_resp mock_session = AsyncMock() mock_session.head = MagicMock(side_effect=make_head_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=mock_session): result = await validator.validate( 'A B' ) assert result.passed is False assert result.issues == ["http://bad.com/b"] @pytest.mark.asyncio async def test_deduplicates_invalid_urls(self, validator): """Same placeholder URL in a tag and a markdown link: issues contain it only once.""" result = await validator.validate( 'a Also see [text](placeholder_url) for details.' ) assert result.passed is False assert result.issues == ["placeholder_url"] @pytest.mark.asyncio async def test_accepts_uppercase_scheme(self, validator): """URLs with uppercase HTTP/HTTPS schemes are treated as valid.""" mock_resp = _mock_response(200) mock_session = AsyncMock() mock_session.head = MagicMock(return_value=mock_resp) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=mock_session): result = await validator.validate('caption') assert result.passed is True assert result.issues == [] def test_feedback_template(self): v = URLValidator(internal_ip="127.0.0.1", feedback_template="Broken: {issues}") feedback = v.format_feedback(["http://bad.com"]) assert "Broken:" in feedback @pytest.mark.asyncio async def test_fails_when_tag_has_backslash_escaped_src(self, validator): """Tag with backslash-escaped quotes around src URL should still be detected.""" result = await validator.validate( '' ) assert result.passed is False assert result.issues == ["http://placeholder.invalid/video.mp4"] @pytest.mark.asyncio async def test_passes_when_backslash_escaped_src_url_returns_200(self, validator): """Tag with backslash-escaped quotes and accessible URL should pass.""" mock_resp = _mock_response(200) mock_session = AsyncMock() mock_session.head = MagicMock(return_value=mock_resp) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=mock_session): result = await validator.validate( '' ) assert result.passed is True assert result.issues == [] class TestExtractUrlsFromTagsWithAlt: """Tests for backslash-escaped quote handling in extract_urls_from_tags_with_alt.""" def test_normal_double_quotes(self): text = '' urls = extract_urls_from_tags_with_alt(text) assert urls == ["http://example.com/v.mp4"] def test_normal_single_quotes(self): text = "" urls = extract_urls_from_tags_with_alt(text) assert urls == ["http://example.com/v.mp4"] def test_backslash_escaped_double_quotes(self): text = '' urls = extract_urls_from_tags_with_alt(text) assert urls == ["http://example.com/v.mp4"] def test_backslash_escaped_single_quotes(self): text = "" urls = extract_urls_from_tags_with_alt(text) assert urls == ["http://example.com/v.mp4"] def test_alt_before_src_with_backslash_quotes(self): text = '\\"Snapshot\\"' urls = extract_urls_from_tags_with_alt(text) assert urls == ["http://example.com/img.jpg"] ================================================ FILE: agent/tests/unit_test/agents/test_data_models.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/agents/data_models.py.""" from vss_agents.agents.data_models import AgentDecision from vss_agents.agents.data_models import AgentMessageChunk from vss_agents.agents.data_models import AgentMessageChunkType from vss_agents.agents.data_models import AgentOutput class TestAgentDecision: """Tests for AgentDecision enum.""" def test_agent_decision_values(self): """Test AgentDecision enum values.""" assert AgentDecision.TOOL.value == "tool" assert AgentDecision.END.value == "finished" assert AgentDecision.AGENT.value == "agent" assert AgentDecision.SUPERVISOR.value == "supervisor" def test_agent_decision_is_string_enum(self): """Test that AgentDecision is a string enum.""" assert isinstance(AgentDecision.TOOL, str) assert AgentDecision.TOOL == "tool" class TestAgentMessageChunkType: """Tests for AgentMessageChunkType enum.""" def test_message_chunk_type_values(self): """Test AgentMessageChunkType enum values.""" assert AgentMessageChunkType.THOUGHT.value == "thought" assert AgentMessageChunkType.TOOL_CALL.value == "tool_call" assert AgentMessageChunkType.SUBAGENT_CALL.value == "subagent_call" assert AgentMessageChunkType.ERROR.value == "error" assert AgentMessageChunkType.FINAL.value == "final" class TestAgentMessageChunk: """Tests for AgentMessageChunk model.""" def test_create_message_chunk_defaults(self): """Test creating AgentMessageChunk with defaults.""" chunk = AgentMessageChunk() assert chunk.type == AgentMessageChunkType.THOUGHT assert chunk.content == "" def test_create_message_chunk_with_values(self): """Test creating AgentMessageChunk with values.""" chunk = AgentMessageChunk( type=AgentMessageChunkType.TOOL_CALL, content="Calling video_caption tool", ) assert chunk.type == AgentMessageChunkType.TOOL_CALL assert chunk.content == "Calling video_caption tool" def test_message_chunk_all_types(self): """Test AgentMessageChunk with all types.""" for chunk_type in AgentMessageChunkType: chunk = AgentMessageChunk(type=chunk_type, content=f"Test {chunk_type}") assert chunk.type == chunk_type def test_message_chunk_long_content(self): """Test AgentMessageChunk with long content.""" long_content = "A" * 10000 chunk = AgentMessageChunk(content=long_content) assert chunk.content == long_content class TestAgentOutput: """Tests for AgentOutput model.""" def test_create_agent_output_defaults(self): """Test creating AgentOutput with defaults.""" output = AgentOutput() assert output.messages == [] assert output.side_effects == {} assert output.metadata == {} assert output.status == "success" assert output.error_message is None def test_create_agent_output_success(self): """Test creating successful AgentOutput.""" output = AgentOutput( messages=["Analysis complete", "Found 3 incidents"], side_effects={"report_html": "..."}, metadata={"generation_time_ms": 1500, "tools_called": ["video_caption"]}, status="success", ) assert len(output.messages) == 2 assert "report_html" in output.side_effects assert output.metadata["generation_time_ms"] == 1500 def test_create_agent_output_error(self): """Test creating error AgentOutput.""" output = AgentOutput( messages=[], status="error", error_message="Failed to process video", ) assert output.status == "error" assert output.error_message == "Failed to process video" def test_create_agent_output_partial_success(self): """Test creating partial success AgentOutput.""" output = AgentOutput( messages=["Partial results available"], status="partial_success", error_message="Some tools failed", ) assert output.status == "partial_success" assert output.error_message == "Some tools failed" def test_agent_output_status_literal(self): """Test AgentOutput status accepts only valid literals.""" # Valid statuses for status in ["success", "partial_success", "error"]: output = AgentOutput(status=status) assert output.status == status def test_agent_output_side_effects_types(self): """Test AgentOutput side_effects with various value types.""" output = AgentOutput( side_effects={ "report_html": "Report", "snapshot_urls": ["http://url1", "http://url2"], "charts": [{"title": "Chart 1", "data": [1, 2, 3]}], "incident_count": 5, } ) assert isinstance(output.side_effects["report_html"], str) assert isinstance(output.side_effects["snapshot_urls"], list) assert isinstance(output.side_effects["incident_count"], int) def test_agent_output_metadata_types(self): """Test AgentOutput metadata with various value types.""" output = AgentOutput( metadata={ "generation_time_ms": 1500, "tools_called": ["tool1", "tool2"], "confidence": 0.95, "agent_iterations": 3, } ) assert output.metadata["generation_time_ms"] == 1500 assert len(output.metadata["tools_called"]) == 2 assert output.metadata["confidence"] == 0.95 def test_agent_output_empty_error_message(self): """Test AgentOutput with empty error message.""" output = AgentOutput(status="error", error_message="") assert output.error_message == "" def test_agent_output_messages_types(self): """Test AgentOutput messages list.""" messages = [ "Starting analysis...", "Processing video frames", "Analysis complete", ] output = AgentOutput(messages=messages) assert output.messages == messages ================================================ FILE: agent/tests/unit_test/agents/test_multi_report_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for multi_report_agent module.""" from pydantic import ValidationError import pytest from vss_agents.agents.multi_report_agent import MultiReportAgentConfig from vss_agents.agents.multi_report_agent import MultiReportAgentInput class TestMultiReportAgentInput: """Test MultiReportAgentInput model.""" def test_input_minimal_sensor(self): input_data = MultiReportAgentInput( source="sensor-001", source_type="sensor", ) assert input_data.source == "sensor-001" assert input_data.source_type == "sensor" assert input_data.start_time is None assert input_data.end_time is None assert input_data.max_result_size is None def test_input_minimal_place(self): input_data = MultiReportAgentInput( source="Building A", source_type="place", ) assert input_data.source_type == "place" def test_input_with_time_range(self): input_data = MultiReportAgentInput( source="sensor-002", source_type="sensor", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", ) assert input_data.start_time == "2025-01-01T00:00:00.000Z" assert input_data.end_time == "2025-01-01T23:59:59.000Z" def test_input_with_max_result_size(self): input_data = MultiReportAgentInput( source="sensor-003", source_type="sensor", max_result_size=50, ) assert input_data.max_result_size == 50 def test_input_max_result_size_must_be_positive(self): with pytest.raises(ValidationError): MultiReportAgentInput( source="sensor", source_type="sensor", max_result_size=0, ) with pytest.raises(ValidationError): MultiReportAgentInput( source="sensor", source_type="sensor", max_result_size=-1, ) def test_input_invalid_source_type(self): with pytest.raises(ValidationError): MultiReportAgentInput( source="test", source_type="invalid", ) class TestMultiReportAgentConfig: """Test MultiReportAgentConfig model.""" def test_config_creation(self): config = MultiReportAgentConfig( multi_incident_tool="multi_incident_formatter", ) assert config.multi_incident_tool == "multi_incident_formatter" assert config.max_incidents == 10000 def test_config_custom_max_incidents(self): config = MultiReportAgentConfig( multi_incident_tool="formatter", max_incidents=100, ) assert config.max_incidents == 100 def test_config_max_incidents_minimum(self): config = MultiReportAgentConfig( multi_incident_tool="formatter", max_incidents=1, ) assert config.max_incidents == 1 def test_config_max_incidents_maximum(self): config = MultiReportAgentConfig( multi_incident_tool="formatter", max_incidents=10000, ) assert config.max_incidents == 10000 def test_config_max_incidents_below_minimum(self): with pytest.raises(ValidationError): MultiReportAgentConfig( multi_incident_tool="formatter", max_incidents=0, ) def test_config_max_incidents_above_maximum(self): with pytest.raises(ValidationError): MultiReportAgentConfig( multi_incident_tool="formatter", max_incidents=10001, ) ================================================ FILE: agent/tests/unit_test/agents/test_report_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for report_agent module.""" from datetime import datetime from pydantic import ValidationError import pytest from vss_agents.agents.report_agent import ReportAgentInput from vss_agents.agents.report_agent import VideoReportAgentInput class TestReportAgentInput: """Test ReportAgentInput model.""" def test_defaults(self): input_data = ReportAgentInput() assert input_data.start_time is None assert input_data.end_time is None assert input_data.incident_id is None assert input_data.source is None assert input_data.source_type is None assert input_data.vlm_reasoning is None def test_with_incident_id(self): input_data = ReportAgentInput(incident_id="incident-123") assert input_data.incident_id == "incident-123" def test_with_time_range(self): start = datetime(2025, 1, 1, 0, 0) end = datetime(2025, 1, 1, 23, 59) input_data = ReportAgentInput(start_time=start, end_time=end) assert input_data.start_time == start assert input_data.end_time == end def test_with_source_sensor(self): input_data = ReportAgentInput(source="sensor-001", source_type="sensor") assert input_data.source == "sensor-001" assert input_data.source_type == "sensor" def test_with_source_place(self): input_data = ReportAgentInput(source="Main Street", source_type="place") assert input_data.source_type == "place" def test_invalid_source_type(self): with pytest.raises(ValidationError): ReportAgentInput(source="test", source_type="invalid") def test_vlm_reasoning_enabled(self): input_data = ReportAgentInput(vlm_reasoning=True) assert input_data.vlm_reasoning is True def test_vlm_reasoning_disabled(self): input_data = ReportAgentInput(vlm_reasoning=False) assert input_data.vlm_reasoning is False class TestVideoReportAgentInput: """Test VideoReportAgentInput model.""" def test_all_fields(self): input_data = VideoReportAgentInput(sensor_id="vst-sensor-001", user_query="What's happening in this video?") assert input_data.sensor_id == "vst-sensor-001" assert input_data.user_query == "What's happening in this video?" def test_missing_sensor_id(self): with pytest.raises(ValidationError): VideoReportAgentInput(user_query="test") def test_only_sensor_id(self): input_data = VideoReportAgentInput(sensor_id="vst-sensor-001") assert input_data.sensor_id == "vst-sensor-001" assert input_data.user_query == "Generate a detailed report of the video." ================================================ FILE: agent/tests/unit_test/agents/test_search_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Unit tests for search_agent.py - focusing on data models, configuration, and presentation converters """ import json from pydantic import ValidationError import pytest from vss_agents.agents.search_agent import SearchAgentConfig from vss_agents.agents.search_agent import SearchAgentInput from vss_agents.agents.search_agent import _helper_markdown_bullet_list from vss_agents.agents.search_agent import _to_chat_response from vss_agents.agents.search_agent import _to_chat_response_chunk from vss_agents.agents.search_agent import _to_incidents_output from vss_agents.tools.search import SearchOutput from vss_agents.tools.search import SearchResult class TestSearchAgentConfig: """Test SearchAgentConfig model.""" def test_required_fields(self): """Test that required fields are enforced.""" config = SearchAgentConfig( embed_search_tool="embed_search", vst_internal_url="http://localhost:30888", ) assert config.embed_search_tool == "embed_search" assert config.attribute_search_tool is None assert config.agent_mode_llm is None assert config.vst_internal_url == "http://localhost:30888" def test_all_fields(self): """Test configuration with all fields.""" config = SearchAgentConfig( embed_search_tool="embed_search", attribute_search_tool="attribute_search", agent_mode_llm="nim_llm", use_attribute_search=True, vst_internal_url="http://localhost:30888", ) assert config.embed_search_tool == "embed_search" assert config.attribute_search_tool == "attribute_search" assert config.agent_mode_llm == "nim_llm" assert config.use_attribute_search is True assert config.vst_internal_url == "http://localhost:30888" def test_defaults(self): """Test default values.""" config = SearchAgentConfig( embed_search_tool="embed_search", vst_internal_url="http://localhost:30888", ) assert config.use_attribute_search is False assert config.attribute_search_tool is None assert config.agent_mode_llm is None assert config.vst_internal_url == "http://localhost:30888" def test_custom_use_attribute_search(self): """Test custom use_attribute_search.""" config = SearchAgentConfig( embed_search_tool="embed_search", use_attribute_search=True, vst_internal_url="http://localhost:30888", ) assert config.use_attribute_search is True assert config.vst_internal_url == "http://localhost:30888" class TestSearchAgentInput: """Test SearchAgentInput model.""" def test_required_query(self): """Test that query is required.""" input_data = SearchAgentInput(query="find person in red shirt") assert input_data.query == "find person in red shirt" def test_missing_query_raises(self): """Test that missing query raises validation error.""" with pytest.raises(ValidationError): SearchAgentInput() def test_defaults(self): """Test default values.""" input_data = SearchAgentInput(query="test query") assert input_data.agent_mode is True assert input_data.use_attribute_search is None assert input_data.max_results == 5 assert input_data.top_k is None assert input_data.start_time is None assert input_data.end_time is None def test_all_fields(self): """Test input with all fields.""" input_data = SearchAgentInput( query="find delivery truck", agent_mode=False, use_attribute_search=False, max_results=10, top_k=20, start_time="2025-01-01T14:00:00Z", end_time="2025-01-01T16:00:00Z", ) assert input_data.query == "find delivery truck" assert input_data.agent_mode is False assert input_data.use_attribute_search is False assert input_data.max_results == 10 assert input_data.top_k == 20 assert input_data.start_time == "2025-01-01T14:00:00Z" assert input_data.end_time == "2025-01-01T16:00:00Z" def test_agent_mode_disabled(self): """Test with agent_mode disabled.""" input_data = SearchAgentInput( query="simple search", agent_mode=False, ) assert input_data.agent_mode is False def test_fusion_disabled(self): """Test with fusion reranking disabled.""" input_data = SearchAgentInput( query="simple search", use_attribute_search=False, ) assert input_data.use_attribute_search is False def test_custom_max_results(self): """Test with custom max_results.""" input_data = SearchAgentInput( query="test query", max_results=15, ) assert input_data.max_results == 15 def test_top_k_override(self): """Test with top_k override.""" input_data = SearchAgentInput( query="test query", max_results=5, top_k=50, ) assert input_data.top_k == 50 assert input_data.max_results == 5 def test_time_filters(self): """Test with time filters.""" input_data = SearchAgentInput( query="time-based search", start_time="2025-01-01T10:00:00Z", end_time="2025-01-01T12:00:00Z", ) assert input_data.start_time == "2025-01-01T10:00:00Z" assert input_data.end_time == "2025-01-01T12:00:00Z" def test_only_start_time(self): """Test with only start_time.""" input_data = SearchAgentInput( query="test query", start_time="2025-01-01T10:00:00Z", ) assert input_data.start_time == "2025-01-01T10:00:00Z" assert input_data.end_time is None def test_only_end_time(self): """Test with only end_time.""" input_data = SearchAgentInput( query="test query", end_time="2025-01-01T12:00:00Z", ) assert input_data.start_time is None assert input_data.end_time == "2025-01-01T12:00:00Z" # ===== Tests for presentation converters (moved from embed_search) ===== def _make_search_output(num_results=1): """Helper to create a SearchOutput with test data.""" results = [] for i in range(num_results): results.append( SearchResult( video_name=f"video{i + 1}.mp4", description=f"Test video {i + 1}", start_time=f"2025-01-15T{10 + i}:00:00Z", end_time=f"2025-01-15T{10 + i}:01:00Z", sensor_id=f"sensor-{i + 1}", screenshot_url=f"http://example.com/screenshot{i + 1}.jpg", similarity=0.95 - (i * 0.1), ) ) return SearchOutput(data=results) class TestToIncidentsOutput: """Test _to_incidents_output function (moved from embed_search).""" def test_empty_search_output(self): output = SearchOutput() result = _to_incidents_output(output) assert "" in result assert "" in result assert '"incidents": []' in result def test_with_results(self): output = _make_search_output(2) result = _to_incidents_output(output) assert "" in result assert "video1.mp4" in result assert "video2.mp4" in result assert "0.95" in result def test_incidents_json_structure(self): output = _make_search_output(1) result = _to_incidents_output(output) # Extract JSON between tags json_start = result.index("\n") + 1 json_end = result.rindex("\n") incidents_json = json.loads(result[json_start:json_end]) assert "incidents" in incidents_json assert len(incidents_json["incidents"]) == 1 incident = incidents_json["incidents"][0] assert "Alert Details" in incident assert "Clip Information" in incident assert incident["Alert Details"]["Alert Triggered"] == "video1.mp4" class TestToChatResponse: """Test _to_chat_response function (moved from embed_search).""" def test_empty_search_output(self): output = SearchOutput() result = _to_chat_response(output) assert result is not None assert hasattr(result, "choices") or hasattr(result, "content") def test_with_results(self): output = _make_search_output(1) result = _to_chat_response(output) assert result is not None class TestToChatResponseChunk: """Test _to_chat_response_chunk function (moved from embed_search).""" def test_empty_search_output(self): output = SearchOutput() result = _to_chat_response_chunk(output) assert result is not None def test_with_results(self): output = _make_search_output(1) result = _to_chat_response_chunk(output) assert result is not None class TestHelperMarkdownBulletList: """Test _helper_markdown_bullet_list function (moved from embed_search).""" def test_empty_search_output(self): output = SearchOutput() result = _helper_markdown_bullet_list(output) assert "```markdown" in result assert "```" in result def test_with_results(self): output = _make_search_output(2) result = _helper_markdown_bullet_list(output) assert "video1.mp4" in result assert "video2.mp4" in result assert "0.95" in result assert "0.85" in result assert "Similarity Score" in result ================================================ FILE: agent/tests/unit_test/agents/test_top_agent.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for top_agent module.""" import pytest from vss_agents.agents.top_agent import EMPTY_MESSAGES_ERROR from vss_agents.agents.top_agent import EMPTY_SCRATCHPAD_ERROR from vss_agents.agents.top_agent import NO_INPUT_ERROR_MESSAGE from vss_agents.agents.top_agent import TOOL_NOT_FOUND_ERROR_MESSAGE from vss_agents.agents.top_agent import strip_frontend_tags class TestTopAgentConstants: """Test top_agent module constants.""" def test_tool_not_found_error_message(self): assert "{tool_name}" in TOOL_NOT_FOUND_ERROR_MESSAGE assert "{tools}" in TOOL_NOT_FOUND_ERROR_MESSAGE def test_no_input_error_message(self): assert "No human input" in NO_INPUT_ERROR_MESSAGE def test_empty_messages_error(self): assert "current_message" in EMPTY_MESSAGES_ERROR def test_empty_scratchpad_error(self): assert "agent_scratchpad" in EMPTY_SCRATCHPAD_ERROR class TestStripFrontendTags: """Test strip_frontend_tags function.""" @pytest.mark.parametrize( "content,expected", [ # HTML img with alt - should remain unchanged ( 'Check this Snapshot at 00:05 image', 'Check this Snapshot at 00:05 image', ), # Self-closing img with alt - should remain unchanged ( 'Incident Chart', 'Incident Chart', ), # Markdown image - should remain unchanged ( "Here is ![Incident Snapshot](http://example.com/img.jpg) the image", "Here is ![Incident Snapshot](http://example.com/img.jpg) the image", ), # Markdown link - should remain unchanged ( "Download [PDF Report](http://example.com/report.pdf) here", "Download [PDF Report](http://example.com/report.pdf) here", ), # Both markdown image and link - should remain unchanged ( "![Snapshot](http://img.jpg) and [Video](http://video.mp4)", "![Snapshot](http://img.jpg) and [Video](http://video.mp4)", ), # Incidents tag - should be replaced ( 'Data: {"incidents": [{"id": "123"}]} end', "Data: [Incident data] end", ), # Multiline incidents tag - should be replaced ( 'Before\n\n{\n "incidents": [{"id": "123"}]\n}\n\nAfter', "Before\n[Incident data]\nAfter", ), # No tags ( "Plain text without any tags", "Plain text without any tags", ), # Empty content ("", ""), # Complex message with multiple elements - only incidents should be replaced ( "Report generated successfully\n**Report Downloads:**\n- [Markdown Report](http://example.com/report.md)\n- [PDF Report](http://example.com/report.pdf)\n\n**Media:**\n- ![Incident Snapshot](http://example.com/snapshot.jpg)\n- [Incident Video](http://example.com/video.mp4)\n", "Report generated successfully\n**Report Downloads:**\n- [Markdown Report](http://example.com/report.md)\n- [PDF Report](http://example.com/report.pdf)\n\n**Media:**\n- ![Incident Snapshot](http://example.com/snapshot.jpg)\n- [Incident Video](http://example.com/video.mp4)\n", ), ], ) def test_strip_frontend_tags(self, content, expected): assert strip_frontend_tags(content) == expected def test_none_content_returns_empty(self): assert strip_frontend_tags(None) == "" ================================================ FILE: agent/tests/unit_test/api/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/tests/unit_test/api/conftest.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """API unit-test guards and shared fixtures.""" import socket import pytest @pytest.fixture(autouse=True) def block_outbound_network(monkeypatch): """Fail fast if a unit test attempts a real network connection.""" def _deny_network(*args, **kwargs): raise AssertionError("API unit tests must not depend on remote endpoints. Mock the network boundary instead.") monkeypatch.setattr(socket, "create_connection", _deny_network) monkeypatch.setattr(socket.socket, "connect", _deny_network, raising=True) monkeypatch.setattr(socket.socket, "connect_ex", _deny_network, raising=True) ================================================ FILE: agent/tests/unit_test/api/test_health_endpoint_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for health_endpoint inner function.""" from unittest.mock import MagicMock import pytest from vss_agents.api.health_endpoint import HealthEndpointConfig from vss_agents.api.health_endpoint import health_endpoint class TestHealthEndpointConfig: """Test HealthEndpointConfig model.""" def test_defaults(self): config = HealthEndpointConfig() assert config.description == "Check if the service is healthy" def test_custom(self): config = HealthEndpointConfig(description="Custom health check") assert config.description == "Custom health check" class TestHealthEndpointInner: """Test the inner _health_endpoint function.""" @pytest.mark.asyncio async def test_health_check(self): config = HealthEndpointConfig() mock_builder = MagicMock() gen = health_endpoint.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn result = await inner_fn(None) assert result == {"isAlive": True} ================================================ FILE: agent/tests/unit_test/api/test_rtsp_stream_api.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for rtsp_stream_api module.""" import os from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.api.rtsp_stream_api import AddStreamRequest from vss_agents.api.rtsp_stream_api import AddStreamResponse from vss_agents.api.rtsp_stream_api import DeleteStreamResponse from vss_agents.api.rtsp_stream_api import ServiceConfig from vss_agents.api.rtsp_stream_api import StreamMode from vss_agents.api.rtsp_stream_api import add_to_rtvi_cv from vss_agents.api.rtsp_stream_api import add_to_rtvi_embed from vss_agents.api.rtsp_stream_api import add_to_vst from vss_agents.api.rtsp_stream_api import cleanup_rtvi_cv from vss_agents.api.rtsp_stream_api import cleanup_rtvi_embed_generation from vss_agents.api.rtsp_stream_api import cleanup_rtvi_embed_stream from vss_agents.api.rtsp_stream_api import cleanup_vst_sensor from vss_agents.api.rtsp_stream_api import cleanup_vst_storage from vss_agents.api.rtsp_stream_api import create_rtsp_stream_api_router from vss_agents.api.rtsp_stream_api import get_stream_info_by_name from vss_agents.api.rtsp_stream_api import register_rtsp_stream_api_routes from vss_agents.api.rtsp_stream_api import start_embedding_generation class TestStreamMode: """Test StreamMode enum.""" def test_search_mode(self): assert StreamMode.SEARCH.value == "search" def test_other_mode(self): assert StreamMode.OTHER.value == "other" def test_from_string(self): assert StreamMode("search") == StreamMode.SEARCH assert StreamMode("other") == StreamMode.OTHER class TestServiceConfig: """Test ServiceConfig class.""" def test_basic_config(self): config = ServiceConfig(vst_internal_url="http://vst:30888") assert config.vst_url == "http://vst:30888" assert config.rtvi_cv_url == "" assert config.rtvi_embed_url == "" assert config.rtvi_embed_model == "cosmos-embed1-448p" assert config.rtvi_embed_chunk_duration == 5 assert config.default_stream_mode == StreamMode.SEARCH def test_full_config(self): config = ServiceConfig( vst_internal_url="http://vst:30888/", rtvi_cv_base_url="http://rtvi-cv:9000/", rtvi_embed_base_url="http://rtvi-embed:8017/", rtvi_embed_model="custom-model", rtvi_embed_chunk_duration=10, default_stream_mode="other", ) assert config.vst_url == "http://vst:30888" assert config.rtvi_cv_url == "http://rtvi-cv:9000" assert config.rtvi_embed_url == "http://rtvi-embed:8017" assert config.rtvi_embed_model == "custom-model" assert config.rtvi_embed_chunk_duration == 10 assert config.default_stream_mode == StreamMode.OTHER class TestAddStreamRequest: """Test AddStreamRequest model.""" def test_required_fields(self): request = AddStreamRequest(sensor_url="rtsp://camera:554/stream", name="camera-1") assert request.sensor_url == "rtsp://camera:554/stream" assert request.name == "camera-1" assert request.username == "" assert request.password == "" assert request.location == "" assert request.tags == "" def test_all_fields(self): request = AddStreamRequest( sensor_url="rtsp://camera:554/stream", name="camera-1", username="admin", password="pw", # pragma: allowlist secret location="Building A", tags="entrance,security", ) assert request.username == "admin" assert request.password == "pw" # pragma: allowlist secret assert request.location == "Building A" assert request.tags == "entrance,security" def test_alias_sensor_url(self): """Test that sensorUrl alias works.""" request = AddStreamRequest(sensorUrl="rtsp://camera:554/stream", name="camera-1") assert request.sensor_url == "rtsp://camera:554/stream" def test_missing_required_fields_fails(self): with pytest.raises(Exception): AddStreamRequest(name="camera-1") # Missing sensor_url class TestAddStreamResponse: """Test AddStreamResponse model.""" def test_success_response(self): response = AddStreamResponse(status="success", message="Stream added successfully") assert response.status == "success" assert response.message == "Stream added successfully" assert response.error is None def test_failure_response(self): response = AddStreamResponse(status="failure", message="Failed to add stream", error="VST error") assert response.status == "failure" assert response.error == "VST error" class TestDeleteStreamResponse: """Test DeleteStreamResponse model.""" def test_success_response(self): response = DeleteStreamResponse(status="success", message="Stream deleted", name="camera-1") assert response.status == "success" assert response.name == "camera-1" def test_partial_response(self): response = DeleteStreamResponse(status="partial", message="Partially deleted", name="camera-1") assert response.status == "partial" class TestAddToVst: """Test add_to_vst function.""" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.vst_add_sensor") @patch("vss_agents.api.rtsp_stream_api.vst_get_rtsp_url") async def test_successful_add(self, mock_get_rtsp_url, mock_add_sensor): config = ServiceConfig(vst_internal_url="http://vst:30888") request = AddStreamRequest(sensor_url="rtsp://camera:554/stream", name="camera-1") # Mock VST add sensor mock_add_sensor.return_value = (True, "OK", "sensor-123") # Mock VST get RTSP URL mock_get_rtsp_url.return_value = (True, "OK", "rtsp://vst:554/sensor-123") success, _msg, sensor_id, rtsp_url = await add_to_vst(config, request) assert success is True assert sensor_id == "sensor-123" assert rtsp_url == "rtsp://vst:554/sensor-123" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.vst_add_sensor") async def test_vst_returns_error(self, mock_add_sensor): config = ServiceConfig(vst_internal_url="http://vst:30888") request = AddStreamRequest(sensor_url="rtsp://camera:554/stream", name="camera-1") mock_add_sensor.return_value = (False, "VST returned 500: Internal Server Error", None) success, msg, sensor_id, _rtsp_url = await add_to_vst(config, request) assert success is False assert "500" in msg assert sensor_id is None @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.vst_add_sensor") async def test_vst_missing_sensor_id(self, mock_add_sensor): config = ServiceConfig(vst_internal_url="http://vst:30888") request = AddStreamRequest(sensor_url="rtsp://camera:554/stream", name="camera-1") mock_add_sensor.return_value = (False, "VST response missing sensor ID: {}", None) success, msg, _sensor_id, _rtsp_url = await add_to_vst(config, request) assert success is False assert "missing sensor ID" in msg class TestAddToRtviCv: """Test add_to_rtvi_cv function.""" @pytest.mark.asyncio async def test_successful_add(self): mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_cv_base_url="http://rtvi-cv:9000") mock_response = MagicMock() mock_response.status_code = 200 mock_client.post = AsyncMock(return_value=mock_response) success, msg = await add_to_rtvi_cv(mock_client, config, "sensor-123", "camera-1", "rtsp://vst:554/sensor-123") assert success is True assert msg == "OK" @pytest.mark.asyncio async def test_skipped_when_not_configured(self): mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_cv_base_url="") success, _msg = await add_to_rtvi_cv(mock_client, config, "sensor-123", "camera-1", "rtsp://vst:554/sensor-123") assert success is True assert "Skipped" in _msg mock_client.post.assert_not_called() @pytest.mark.asyncio async def test_rtvi_cv_error(self): mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_cv_base_url="http://rtvi-cv:9000") mock_response = MagicMock() mock_response.status_code = 500 mock_response.text = "Error" mock_client.post = AsyncMock(return_value=mock_response) success, msg = await add_to_rtvi_cv(mock_client, config, "sensor-123", "camera-1", "rtsp://vst:554/sensor-123") assert success is False assert "500" in msg class TestAddToRtviEmbed: """Test add_to_rtvi_embed function.""" @pytest.mark.asyncio async def test_successful_add(self): mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_embed_base_url="http://rtvi-embed:8017") mock_response = MagicMock() mock_response.status_code = 200 mock_response.json = MagicMock(return_value={"streams": [{"id": "rtvi-stream-123"}]}) mock_client.post = AsyncMock(return_value=mock_response) success, _msg, stream_id = await add_to_rtvi_embed( mock_client, config, "sensor-123", "camera-1", "rtsp://vst:554/sensor-123" ) assert success is True assert stream_id == "rtvi-stream-123" @pytest.mark.asyncio async def test_skipped_when_not_configured(self): mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_embed_base_url="") success, _msg, stream_id = await add_to_rtvi_embed( mock_client, config, "sensor-123", "camera-1", "rtsp://vst:554/sensor-123" ) assert success is True assert "Skipped" in _msg assert stream_id == "sensor-123" # Falls back to sensor_id @pytest.mark.asyncio async def test_fallback_to_sensor_id(self): """Test that stream_id falls back to sensor_id when not in response.""" mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_embed_base_url="http://rtvi-embed:8017") mock_response = MagicMock() mock_response.status_code = 200 mock_response.json = MagicMock(return_value={"streams": []}) # Empty streams mock_client.post = AsyncMock(return_value=mock_response) success, _msg, stream_id = await add_to_rtvi_embed( mock_client, config, "sensor-123", "camera-1", "rtsp://vst:554/sensor-123" ) assert success is True assert stream_id == "sensor-123" class TestStartEmbeddingGeneration: """Test start_embedding_generation function.""" @pytest.mark.asyncio async def test_successful_start(self): config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_embed_base_url="http://rtvi-embed:8017") # Create mock response for streaming context manager mock_response = MagicMock() mock_response.status_code = 200 # Create stream context manager mock_stream_cm = MagicMock() mock_stream_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_stream_cm.__aexit__ = AsyncMock(return_value=None) # Create mock client with stream method mock_client = MagicMock() mock_client.stream = MagicMock(return_value=mock_stream_cm) success, msg = await start_embedding_generation(mock_client, config, "stream-123") assert success is True assert msg == "OK" @pytest.mark.asyncio async def test_skipped_when_not_configured(self): config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_embed_base_url="") mock_client = MagicMock() success, msg = await start_embedding_generation(mock_client, config, "stream-123") assert success is True assert "Skipped" in msg class TestGetStreamInfoByName: """Test get_stream_info_by_name function.""" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.vst_get_stream_info_by_name") async def test_successful_lookup(self, mock_vst_get_stream_info): config = ServiceConfig(vst_internal_url="http://vst:30888") mock_vst_get_stream_info.return_value = ("sensor-123", "rtsp://vst:554/sensor-123") success, _msg, stream_id, rtsp_url = await get_stream_info_by_name(config, "camera-1") assert success is True assert stream_id == "sensor-123" assert rtsp_url == "rtsp://vst:554/sensor-123" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.vst_get_stream_info_by_name") async def test_name_not_found(self, mock_vst_get_stream_info): config = ServiceConfig(vst_internal_url="http://vst:30888") mock_vst_get_stream_info.return_value = (None, None) success, msg, _stream_id, _rtsp_url = await get_stream_info_by_name(config, "camera-1") assert success is False assert "not found" in msg class TestCleanupFunctions: """Test cleanup functions.""" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.vst_delete_sensor") async def test_cleanup_vst_sensor_success(self, mock_vst_delete_sensor): config = ServiceConfig(vst_internal_url="http://vst:30888") mock_vst_delete_sensor.return_value = (True, "OK") success, _msg = await cleanup_vst_sensor(config, "sensor-123") assert success is True @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.vst_delete_storage") async def test_cleanup_vst_storage_no_timeline(self, mock_vst_delete_storage): config = ServiceConfig(vst_internal_url="http://vst:30888") mock_vst_delete_storage.return_value = (True, "No storage to delete") success, msg = await cleanup_vst_storage(config, "sensor-123") assert success is True assert "No storage to delete" in msg @pytest.mark.asyncio async def test_cleanup_rtvi_cv_skipped(self): mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_cv_base_url="") success, msg = await cleanup_rtvi_cv(mock_client, config, "sensor-123") assert success is True assert "Skipped" in msg @pytest.mark.asyncio async def test_cleanup_rtvi_embed_stream_success(self): mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_embed_base_url="http://rtvi-embed:8017") mock_response = MagicMock() mock_response.status_code = 200 mock_client.delete = AsyncMock(return_value=mock_response) success, _msg = await cleanup_rtvi_embed_stream(mock_client, config, "stream-123") assert success is True @pytest.mark.asyncio async def test_cleanup_rtvi_embed_generation_success(self): mock_client = MagicMock() config = ServiceConfig(vst_internal_url="http://vst:30888", rtvi_embed_base_url="http://rtvi-embed:8017") mock_response = MagicMock() mock_response.status_code = 200 mock_client.delete = AsyncMock(return_value=mock_response) success, _msg = await cleanup_rtvi_embed_generation(mock_client, config, "stream-123") assert success is True class TestCreateRtspStreamApiRouter: """Test create_rtsp_stream_api_router function.""" def test_create_router(self): router = create_rtsp_stream_api_router(vst_internal_url="http://vst:30888") assert router is not None def test_create_router_with_all_params(self): router = create_rtsp_stream_api_router( vst_internal_url="http://vst:30888", rtvi_cv_base_url="http://rtvi-cv:9000", rtvi_embed_base_url="http://rtvi-embed:8017", rtvi_embed_model="custom-model", rtvi_embed_chunk_duration=10, default_stream_mode="other", ) assert router is not None def test_router_has_routes(self): router = create_rtsp_stream_api_router(vst_internal_url="http://vst:30888") assert len(router.routes) == 2 # add and delete endpoints class TestAddStreamEndpoint: """Test add_stream endpoint.""" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.start_embedding_generation") @patch("vss_agents.api.rtsp_stream_api.add_to_rtvi_embed") @patch("vss_agents.api.rtsp_stream_api.add_to_rtvi_cv") @patch("vss_agents.api.rtsp_stream_api.add_to_vst") @patch("vss_agents.api.rtsp_stream_api.httpx.AsyncClient") async def test_successful_add_search_mode( self, mock_client_class, mock_add_vst, mock_add_rtvi_cv, mock_add_rtvi_embed, mock_start_embed ): """Test successful stream addition in search mode.""" router = create_rtsp_stream_api_router( vst_internal_url="http://vst:30888", rtvi_cv_base_url="http://rtvi-cv:9000", rtvi_embed_base_url="http://rtvi-embed:8017", default_stream_mode="search", ) # Mock httpx client mock_client = MagicMock() mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None) # Mock all helper functions mock_add_vst.return_value = (True, "OK", "sensor-123", "rtsp://vst:554/sensor-123") mock_add_rtvi_cv.return_value = (True, "OK") mock_add_rtvi_embed.return_value = (True, "OK", "sensor-123") mock_start_embed.return_value = (True, "OK") # Get endpoint and call endpoint = router.routes[0].endpoint request = AddStreamRequest(sensor_url="rtsp://camera:554/stream", name="camera-1") response = await endpoint(request) assert response.status == "success" assert "camera-1" in response.message @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.add_to_vst") async def test_successful_add_other_mode(self, mock_add_vst): """Test successful stream addition in 'other' mode (VST only).""" router = create_rtsp_stream_api_router( vst_internal_url="http://vst:30888", default_stream_mode="other", ) # Mock VST add mock_add_vst.return_value = (True, "OK", "sensor-123", "rtsp://vst:554/sensor-123") endpoint = router.routes[0].endpoint request = AddStreamRequest(sensor_url="rtsp://camera:554/stream", name="camera-1") response = await endpoint(request) assert response.status == "success" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.add_to_vst") async def test_vst_failure_no_rollback_needed(self, mock_add_vst): """Test that VST failure doesn't trigger rollback (nothing to rollback).""" router = create_rtsp_stream_api_router( vst_internal_url="http://vst:30888", default_stream_mode="search", ) mock_add_vst.return_value = (False, "VST returned 500: Server error", None, None) endpoint = router.routes[0].endpoint request = AddStreamRequest(sensor_url="rtsp://camera:554/stream", name="camera-1") response = await endpoint(request) assert response.status == "failure" assert "VST" in response.message @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.cleanup_vst_storage") @patch("vss_agents.api.rtsp_stream_api.cleanup_vst_sensor") @patch("vss_agents.api.rtsp_stream_api.add_to_rtvi_cv") @patch("vss_agents.api.rtsp_stream_api.add_to_vst") @patch("vss_agents.api.rtsp_stream_api.httpx.AsyncClient") async def test_rtvi_cv_failure_triggers_rollback( self, mock_client_class, mock_add_vst, mock_add_rtvi_cv, mock_cleanup_sensor, mock_cleanup_storage ): """Test that RTVI-CV failure triggers VST cleanup.""" router = create_rtsp_stream_api_router( vst_internal_url="http://vst:30888", rtvi_cv_base_url="http://rtvi-cv:9000", rtvi_embed_base_url="http://rtvi-embed:8017", default_stream_mode="search", ) # Mock httpx client mock_client = MagicMock() mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None) # VST success, RTVI-CV failure mock_add_vst.return_value = (True, "OK", "sensor-123", "rtsp://vst:554/sensor-123") mock_add_rtvi_cv.return_value = (False, "RTVI-CV error") mock_cleanup_sensor.return_value = (True, "OK") mock_cleanup_storage.return_value = (True, "OK") endpoint = router.routes[0].endpoint request = AddStreamRequest(sensor_url="rtsp://camera:554/stream", name="camera-1") response = await endpoint(request) assert response.status == "failure" assert "RTVI-CV" in response.message # Should have called cleanup functions mock_cleanup_sensor.assert_called_once() mock_cleanup_storage.assert_called_once() class TestDeleteStreamEndpoint: """Test delete_stream endpoint.""" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.cleanup_vst_sensor") @patch("vss_agents.api.rtsp_stream_api.cleanup_rtvi_cv") @patch("vss_agents.api.rtsp_stream_api.cleanup_rtvi_embed_stream") @patch("vss_agents.api.rtsp_stream_api.cleanup_rtvi_embed_generation") @patch("vss_agents.api.rtsp_stream_api.get_stream_info_by_name") @patch("vss_agents.api.rtsp_stream_api.httpx.AsyncClient") async def test_successful_delete_search_mode( self, mock_client_class, mock_get_stream_info, mock_cleanup_embed_gen, mock_cleanup_embed_stream, mock_cleanup_rtvi_cv, mock_cleanup_vst_sensor, ): """Test successful stream deletion in search mode.""" router = create_rtsp_stream_api_router( vst_internal_url="http://vst:30888", rtvi_cv_base_url="http://rtvi-cv:9000", rtvi_embed_base_url="http://rtvi-embed:8017", default_stream_mode="search", ) # Mock httpx client mock_client = MagicMock() mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None) # Mock all helper functions mock_get_stream_info.return_value = (True, "OK", "sensor-123", "rtsp://vst:554/sensor-123") mock_cleanup_embed_gen.return_value = (True, "OK") mock_cleanup_embed_stream.return_value = (True, "OK") mock_cleanup_rtvi_cv.return_value = (True, "OK") mock_cleanup_vst_sensor.return_value = (True, "OK") endpoint = router.routes[1].endpoint response = await endpoint(name="camera-1") assert response.status == "success" assert response.name == "camera-1" @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.get_stream_info_by_name") async def test_delete_stream_not_found(self, mock_get_stream_info): """Test deletion when stream is not found.""" router = create_rtsp_stream_api_router( vst_internal_url="http://vst:30888", default_stream_mode="search", ) mock_get_stream_info.return_value = (False, "Stream not found", None, None) endpoint = router.routes[1].endpoint response = await endpoint(name="nonexistent-camera") assert response.status == "failure" assert "not found" in response.message.lower() or "Failed to find" in response.message @pytest.mark.asyncio @patch("vss_agents.api.rtsp_stream_api.cleanup_vst_sensor") @patch("vss_agents.api.rtsp_stream_api.cleanup_rtvi_cv") @patch("vss_agents.api.rtsp_stream_api.cleanup_rtvi_embed_stream") @patch("vss_agents.api.rtsp_stream_api.cleanup_rtvi_embed_generation") @patch("vss_agents.api.rtsp_stream_api.get_stream_info_by_name") @patch("vss_agents.api.rtsp_stream_api.httpx.AsyncClient") async def test_partial_delete( self, mock_client_class, mock_get_stream_info, mock_cleanup_embed_gen, mock_cleanup_embed_stream, mock_cleanup_rtvi_cv, mock_cleanup_vst_sensor, ): """Test partial deletion when some services fail.""" router = create_rtsp_stream_api_router( vst_internal_url="http://vst:30888", rtvi_cv_base_url="http://rtvi-cv:9000", rtvi_embed_base_url="http://rtvi-embed:8017", default_stream_mode="search", ) # Mock httpx client mock_client = MagicMock() mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None) # Mock helper functions with mixed success/failure mock_get_stream_info.return_value = (True, "OK", "sensor-123", "rtsp://vst:554/sensor-123") mock_cleanup_embed_gen.return_value = (True, "OK") mock_cleanup_embed_stream.return_value = (False, "Error") # Failure mock_cleanup_rtvi_cv.return_value = (True, "OK") mock_cleanup_vst_sensor.return_value = (True, "OK") endpoint = router.routes[1].endpoint response = await endpoint(name="camera-1") assert response.status == "partial" class TestRegisterRtspStreamApiRoutes: """Test register_rtsp_stream_api_routes function.""" def test_register_with_config(self): """Test registering routes using config object.""" mock_app = MagicMock() mock_config = MagicMock() mock_streaming_config = MagicMock() mock_streaming_config.vst_internal_url = "http://vst:30888" mock_streaming_config.rtvi_cv_base_url = "http://rtvi-cv:9000" mock_streaming_config.rtvi_embed_base_url = "http://rtvi-embed:8017" mock_streaming_config.rtvi_embed_model = "test-model" mock_streaming_config.rtvi_embed_chunk_duration = 10 mock_streaming_config.stream_mode = "search" mock_config.general.front_end.streaming_ingest = mock_streaming_config register_rtsp_stream_api_routes(mock_app, mock_config) assert mock_app.include_router.called def test_register_with_env_vars(self): """Test registering routes using environment variables.""" mock_app = MagicMock() mock_config = MagicMock() mock_config.general.front_end = MagicMock(spec=[]) # No streaming_ingest attribute with patch.dict( os.environ, { "VST_INTERNAL_URL": "http://vst:30888", "HOST_IP": "127.0.0.1", "RTVI_EMBED_PORT": "8017", }, ): register_rtsp_stream_api_routes(mock_app, mock_config) assert mock_app.include_router.called def test_register_missing_vst_url(self): """Test error when VST_INTERNAL_URL is not set.""" mock_app = MagicMock() mock_config = MagicMock() mock_config.general.front_end = MagicMock(spec=[]) with patch.dict(os.environ, {}, clear=True), pytest.raises(ValueError, match="VST_INTERNAL_URL"): register_rtsp_stream_api_routes(mock_app, mock_config) def test_register_missing_rtvi_embed_url(self): """Test error when RTVI-embed URL is not configured.""" mock_app = MagicMock() mock_config = MagicMock() mock_config.general.front_end = MagicMock(spec=[]) with patch.dict(os.environ, {"VST_INTERNAL_URL": "http://vst:30888"}, clear=True): with pytest.raises(ValueError, match="RTVI-embed"): register_rtsp_stream_api_routes(mock_app, mock_config) ================================================ FILE: agent/tests/unit_test/api/test_video_search_ingest.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_search_ingest module.""" import os from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import Mock from unittest.mock import patch from fastapi import HTTPException import pytest from vss_agents.api.video_search_ingest import ALLOWED_VIDEO_TYPES from vss_agents.api.video_search_ingest import VideoIngestResponse from vss_agents.api.video_search_ingest import create_streaming_video_ingest_router from vss_agents.api.video_search_ingest import register_streaming_routes class TestAllowedVideoTypes: """Test ALLOWED_VIDEO_TYPES constant.""" def test_mp4_allowed(self): assert "video/mp4" in ALLOWED_VIDEO_TYPES def test_mkv_allowed(self): assert "video/x-matroska" in ALLOWED_VIDEO_TYPES def test_only_two_types(self): assert len(ALLOWED_VIDEO_TYPES) == 2 class TestVideoIngestResponse: """Test VideoIngestResponse model.""" def test_response_creation(self): response = VideoIngestResponse( message="Upload complete", video_id="video-001", filename="test_video.mp4", chunks_processed=10 ) assert response.message == "Upload complete" assert response.video_id == "video-001" assert response.filename == "test_video.mp4" assert response.chunks_processed == 10 def test_response_default_chunks(self): response = VideoIngestResponse(message="Done", video_id="vid-002", filename="another_video.mp4") assert response.chunks_processed == 0 def test_response_serialization(self): response = VideoIngestResponse( message="Test", video_id="test-id", filename="serialized_video.mp4", chunks_processed=5 ) data = response.model_dump() assert data["message"] == "Test" assert data["video_id"] == "test-id" assert data["filename"] == "serialized_video.mp4" assert data["chunks_processed"] == 5 class TestCreateStreamingVideoIngestRouter: """Test create_streaming_video_ingest_router function.""" def test_create_router(self): router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) assert router is not None def test_create_router_custom_params(self): router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080", rtvi_embed_model="custom-model", rtvi_embed_chunk_duration=10, ) assert router is not None def test_router_has_routes(self): router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) # Router should have routes registered assert len(router.routes) > 0 class TestStreamVideoToVstEndpoint: """Test stream_video_to_vst endpoint logic.""" @pytest.mark.asyncio async def test_successful_upload(self): """Test successful video upload flow.""" router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) # Create mock request mock_request = MagicMock() mock_request.headers = {"content-type": "video/mp4", "content-length": "1024"} mock_request.stream = AsyncMock(return_value=iter([b"test data"])) # Mock external boundaries (HTTP + timeline helper) with ( patch("vss_agents.api.video_search_ingest.httpx.AsyncClient") as mock_client_class, patch("vss_agents.api.video_search_ingest.get_timeline", new_callable=AsyncMock) as mock_get_timeline, ): mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_get_timeline.return_value = ("1000", "2000") # Mock VST upload response mock_vst_response = Mock() mock_vst_response.status_code = 200 mock_vst_response.json = Mock(return_value={"sensorId": "sensor-123"}) # Mock storage response mock_storage_response = Mock() mock_storage_response.status_code = 200 mock_storage_response.json = Mock(return_value={"videoUrl": "http://vst/video.mp4"}) # Mock embedding response mock_embed_response = Mock() mock_embed_response.status_code = 200 mock_embed_response.json = Mock(return_value={"usage": {"total_chunks_processed": 5}}) # Set up mock client responses mock_client.put.return_value = mock_vst_response mock_client.get.return_value = mock_storage_response mock_client.post.return_value = mock_embed_response # Get the endpoint function endpoint = router.routes[0].endpoint # Call the endpoint response = await endpoint(filename="test.mp4", request=mock_request) assert response.video_id == "sensor-123" assert response.chunks_processed == 5 assert "successfully uploaded" in response.message @pytest.mark.asyncio async def test_missing_content_type(self): """Test error when Content-Type header is missing.""" router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) mock_request = MagicMock() mock_request.headers = {"content-length": "1024"} endpoint = router.routes[0].endpoint with pytest.raises(HTTPException) as exc_info: await endpoint(filename="test.mp4", request=mock_request) assert exc_info.value.status_code == 400 assert "Content-Type header is required" in exc_info.value.detail @pytest.mark.asyncio async def test_invalid_content_type(self): """Test error when Content-Type is not allowed.""" router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) mock_request = MagicMock() mock_request.headers = { "content-type": "video/webm", # Not allowed "content-length": "1024", } endpoint = router.routes[0].endpoint with pytest.raises(HTTPException) as exc_info: await endpoint(filename="test.mp4", request=mock_request) assert exc_info.value.status_code == 415 assert "Unsupported video format" in exc_info.value.detail @pytest.mark.asyncio async def test_missing_content_length(self): """Test error when Content-Length header is missing.""" router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) mock_request = MagicMock() mock_request.headers = {"content-type": "video/mp4"} endpoint = router.routes[0].endpoint with pytest.raises(HTTPException) as exc_info: await endpoint(filename="test.mp4", request=mock_request) assert exc_info.value.status_code == 400 assert "Content-Length header is required" in exc_info.value.detail @pytest.mark.asyncio async def test_zero_content_length(self): """Test error when Content-Length is zero.""" router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) mock_request = MagicMock() mock_request.headers = {"content-type": "video/mp4", "content-length": "0"} endpoint = router.routes[0].endpoint with pytest.raises(HTTPException) as exc_info: await endpoint(filename="test.mp4", request=mock_request) assert exc_info.value.status_code == 400 assert "File is empty" in exc_info.value.detail @pytest.mark.asyncio async def test_invalid_content_length_format(self): """Test error when Content-Length is not a valid integer.""" router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) mock_request = MagicMock() mock_request.headers = {"content-type": "video/mp4", "content-length": "invalid"} endpoint = router.routes[0].endpoint with pytest.raises(HTTPException) as exc_info: await endpoint(filename="test.mp4", request=mock_request) assert exc_info.value.status_code == 400 assert "Invalid Content-Length" in exc_info.value.detail @pytest.mark.asyncio async def test_vst_upload_failure(self): """Test error when VST upload fails.""" router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) mock_request = MagicMock() mock_request.headers = {"content-type": "video/mp4", "content-length": "1024"} mock_request.stream = AsyncMock(return_value=iter([b"test data"])) with ( patch("vss_agents.api.video_search_ingest.httpx.AsyncClient") as mock_client_class, patch("vss_agents.api.video_search_ingest.get_timeline", new_callable=AsyncMock) as mock_get_timeline, ): mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_get_timeline.return_value = ("1000", "2000") mock_vst_response = Mock() mock_vst_response.status_code = 500 mock_vst_response.text = "Server error" mock_client.put.return_value = mock_vst_response endpoint = router.routes[0].endpoint with pytest.raises(HTTPException) as exc_info: await endpoint(filename="test.mp4", request=mock_request) assert exc_info.value.status_code == 502 assert "VST upload failed" in exc_info.value.detail @pytest.mark.asyncio async def test_filename_without_extension(self): """Test handling filename without extension.""" router = create_streaming_video_ingest_router( vst_internal_url="http://vst:8080", rtvi_embed_base_url="http://rtvi:8080" ) mock_request = MagicMock() mock_request.headers = {"content-type": "video/mp4", "content-length": "1024"} mock_request.stream = AsyncMock(return_value=iter([b"test data"])) with ( patch("vss_agents.api.video_search_ingest.httpx.AsyncClient") as mock_client_class, patch("vss_agents.api.video_search_ingest.get_timeline", new_callable=AsyncMock) as mock_get_timeline, ): mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_get_timeline.return_value = ("1000", "2000") mock_vst_response = Mock() mock_vst_response.status_code = 200 mock_vst_response.json = Mock(return_value={"sensorId": "sensor-123"}) mock_storage_response = Mock() mock_storage_response.status_code = 200 mock_storage_response.json = Mock(return_value={"videoUrl": "http://vst/video.mp4"}) mock_embed_response = Mock() mock_embed_response.status_code = 200 mock_embed_response.json = Mock(return_value={"usage": {"total_chunks_processed": 3}}) mock_client.put.return_value = mock_vst_response mock_client.get.return_value = mock_storage_response mock_client.post.return_value = mock_embed_response endpoint = router.routes[0].endpoint response = await endpoint(filename="test_video", request=mock_request) assert response.video_id == "sensor-123" class TestRegisterStreamingRoutes: """Test register_streaming_routes function.""" def test_register_with_env_vars(self): """Test registering routes using environment variables.""" mock_app = MagicMock() mock_config = MagicMock() mock_config.general.front_end = MagicMock(spec=[]) # No streaming_ingest attribute with patch.dict( os.environ, {"VST_INTERNAL_URL": "http://vst:8080", "HOST_IP": "127.0.0.1", "RTVI_EMBED_PORT": "8017"} ): register_streaming_routes(mock_app, mock_config) # Should call include_router once assert mock_app.include_router.called def test_register_with_config(self): """Test registering routes using config object.""" mock_app = MagicMock() mock_config = MagicMock() mock_streaming_config = MagicMock() mock_streaming_config.vst_internal_url = "http://vst:8080" mock_streaming_config.rtvi_embed_base_url = "http://rtvi:8080" mock_streaming_config.rtvi_embed_model = "test-model" mock_streaming_config.rtvi_embed_chunk_duration = 10 mock_config.general.front_end.streaming_ingest = mock_streaming_config register_streaming_routes(mock_app, mock_config) assert mock_app.include_router.called def test_register_missing_vst_url(self): """Test error when VST_INTERNAL_URL is not set.""" mock_app = MagicMock() mock_config = MagicMock() mock_config.general.front_end = MagicMock(spec=[]) with patch.dict(os.environ, {}, clear=True), pytest.raises(ValueError, match="VST_INTERNAL_URL"): register_streaming_routes(mock_app, mock_config) def test_register_missing_rtvi_url(self): """Test error when RTVI URL is not configured.""" mock_app = MagicMock() mock_config = MagicMock() mock_config.general.front_end = MagicMock(spec=[]) with patch.dict(os.environ, {"VST_INTERNAL_URL": "http://vst:8080"}, clear=True): with pytest.raises(ValueError, match="HOST_IP and RTVI_EMBED_PORT"): register_streaming_routes(mock_app, mock_config) ================================================ FILE: agent/tests/unit_test/api/test_video_upload_url.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_upload_url module.""" from pydantic import ValidationError import pytest from vss_agents.api.video_upload_url import VideoUploadURLConfig from vss_agents.api.video_upload_url import VideoUploadURLInput from vss_agents.api.video_upload_url import VideoUploadURLOutput class TestVideoUploadURLConfig: """Test VideoUploadURLConfig model.""" def test_with_required_fields(self): config = VideoUploadURLConfig( vst_external_url="http://vst:8080", agent_base_url="http://agent:8000", ) assert config.vst_external_url == "http://vst:8080" assert config.agent_base_url == "http://agent:8000" def test_missing_vst_external_url_fails(self): with pytest.raises(ValidationError): VideoUploadURLConfig(agent_base_url="http://agent:8000") def test_missing_agent_base_url_fails(self): with pytest.raises(ValidationError): VideoUploadURLConfig(vst_external_url="http://vst:8080") class TestVideoUploadURLInput: """Test VideoUploadURLInput model.""" def test_basic_input(self): input_data = VideoUploadURLInput(filename="video.mp4") assert input_data.filename == "video.mp4" assert input_data.embedding is False def test_with_embedding(self): input_data = VideoUploadURLInput( filename="video.mp4", embedding=True, ) assert input_data.embedding is True def test_empty_filename_fails(self): with pytest.raises(ValidationError): VideoUploadURLInput(filename="") def test_missing_filename_fails(self): with pytest.raises(ValidationError): VideoUploadURLInput() def test_various_filenames(self): filenames = ["test.mp4", "camera_1_2025.mkv", "incident-001.mp4", "a.mp4"] for filename in filenames: input_data = VideoUploadURLInput(filename=filename) assert input_data.filename == filename class TestVideoUploadURLOutput: """Test VideoUploadURLOutput model.""" def test_output_creation(self): output = VideoUploadURLOutput(url="http://vst:8080/vst/api/v1/storage/file/test/2025-01-01T00:00:00.000Z") assert "vst" in output.url assert "storage" in output.url def test_output_with_different_urls(self): urls = [ "http://vst:8080/vst/api/v1/storage/file/video1/2025-01-01T00:00:00.000Z", "http://agent:8000/api/v1/videos-for-search/video2", "https://secure-vst.example.com/storage/video3", ] for url in urls: output = VideoUploadURLOutput(url=url) assert output.url == url class TestVideoUploadURLFunction: """Test the video_upload_url function logic directly.""" def test_vst_url_construction(self): """Test VST URL construction logic.""" # Simulate the URL construction logic from the function vst_base_url = "http://vst:8080" filename = "test_video.mp4" base_url = vst_base_url.rstrip("/") filename_without_ext = filename.rsplit(".", 1)[0] or filename timestamp = "2025-01-01T00:00:00.000Z" url = f"{base_url}/vst/api/v1/storage/file/{filename_without_ext}/{timestamp}" assert url == "http://vst:8080/vst/api/v1/storage/file/test_video/2025-01-01T00:00:00.000Z" def test_embedding_url_construction(self): """Test embedding URL construction logic.""" agent_base_url = "http://agent:8000" filename = "test_video.mp4" agent_base = agent_base_url.rstrip("/") filename_without_ext = filename.rsplit(".", 1)[0] or filename url = f"{agent_base}/api/v1/videos-for-search/{filename_without_ext}" assert url == "http://agent:8000/api/v1/videos-for-search/test_video" def test_url_with_trailing_slash(self): """Test URL generation strips trailing slash.""" vst_base_url = "http://vst:8080/" agent_base_url = "http://agent:8000/" vst_stripped = vst_base_url.rstrip("/") agent_stripped = agent_base_url.rstrip("/") assert vst_stripped == "http://vst:8080" assert agent_stripped == "http://agent:8000" def test_filename_without_extension(self): """Test filename without extension handling.""" filename = "video_no_ext" filename_without_ext = filename.rsplit(".", 1)[0] or filename assert filename_without_ext == "video_no_ext" def test_filename_with_multiple_dots(self): """Test filename with multiple dots.""" filename = "video.2025.01.01.mp4" filename_without_ext = filename.rsplit(".", 1)[0] or filename assert filename_without_ext == "video.2025.01.01" def test_input_json_parsing(self): """Test input can be created from JSON.""" json_str = '{"filename": "test.mp4", "embedding": true}' input_data = VideoUploadURLInput.model_validate_json(json_str) assert input_data.filename == "test.mp4" assert input_data.embedding is True def test_output_json_serialization(self): """Test output can be serialized to JSON.""" output = VideoUploadURLOutput(url="http://example.com/video") json_str = output.model_dump_json() assert "http://example.com/video" in json_str ================================================ FILE: agent/tests/unit_test/api/test_video_upload_url_converters.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for video_upload_url converter functions.""" from unittest.mock import MagicMock import pytest from vss_agents.api.video_upload_url import VideoUploadURLConfig from vss_agents.api.video_upload_url import VideoUploadURLInput from vss_agents.api.video_upload_url import VideoUploadURLOutput from vss_agents.api.video_upload_url import video_upload_url class TestVideoUploadURLConverters: """Test converter functions.""" @pytest.fixture def config(self): return VideoUploadURLConfig( vst_external_url="http://1.2.3.4:30888", agent_base_url="http://10.0.0.1:8000", ) @pytest.fixture def mock_builder(self): return MagicMock() @pytest.mark.asyncio async def test_str_input_converter(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) fi = await gen.__anext__() str_converter = fi.converters[0] result = str_converter('{"filename": "test.mp4"}') assert isinstance(result, VideoUploadURLInput) assert result.filename == "test.mp4" @pytest.mark.asyncio async def test_chat_request_input_converter(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) fi = await gen.__anext__() chat_converter = fi.converters[1] mock_message = MagicMock() mock_message.content = '{"filename": "video.mp4"}' mock_request = MagicMock() mock_request.messages = [mock_message] result = chat_converter(mock_request) assert isinstance(result, VideoUploadURLInput) @pytest.mark.asyncio async def test_chat_request_converter_error(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) fi = await gen.__anext__() chat_converter = fi.converters[1] mock_message = MagicMock() mock_message.content = "not json" mock_request = MagicMock() mock_request.messages = [mock_message] with pytest.raises(Exception): chat_converter(mock_request) @pytest.mark.asyncio async def test_output_converter(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) fi = await gen.__anext__() output_converter = fi.converters[2] output = VideoUploadURLOutput(url="http://test.com/upload") result = output_converter(output) assert isinstance(result, str) assert "test.com" in result @pytest.mark.asyncio async def test_chat_response_converter(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) fi = await gen.__anext__() chat_response_converter = fi.converters[3] output = VideoUploadURLOutput(url="http://test.com/upload") # The original code has a bug: ChatResponse.from_string() requires 'usage' # but _chat_response_output_converter doesn't pass it with pytest.raises(TypeError): chat_response_converter(output) ================================================ FILE: agent/tests/unit_test/api/test_video_upload_url_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for video_upload_url module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.api.video_upload_url import VideoUploadURLConfig from vss_agents.api.video_upload_url import VideoUploadURLInput from vss_agents.api.video_upload_url import VideoUploadURLOutput class TestVideoUploadURLConfig: """Test VideoUploadURLConfig model.""" def test_required_fields(self): config = VideoUploadURLConfig( vst_external_url="http://1.2.3.4:30888", agent_base_url="http://10.0.0.1:8000", ) assert config.vst_external_url == "http://1.2.3.4:30888" assert config.agent_base_url == "http://10.0.0.1:8000" def test_missing_vst_url_raises(self): with pytest.raises(ValidationError): VideoUploadURLConfig(agent_base_url="http://10.0.0.1:8000") def test_missing_agent_url_raises(self): with pytest.raises(ValidationError): VideoUploadURLConfig(vst_external_url="http://1.2.3.4:30888") class TestVideoUploadURLInput: """Test VideoUploadURLInput model.""" def test_basic(self): inp = VideoUploadURLInput(filename="video.mp4") assert inp.filename == "video.mp4" assert inp.embedding is False def test_with_embedding(self): inp = VideoUploadURLInput(filename="video.mp4", embedding=True) assert inp.embedding is True def test_empty_filename_raises(self): with pytest.raises(ValidationError): VideoUploadURLInput(filename="") def test_missing_filename_raises(self): with pytest.raises(ValidationError): VideoUploadURLInput() class TestVideoUploadURLOutput: """Test VideoUploadURLOutput model.""" def test_basic(self): output = VideoUploadURLOutput(url="http://example.com/upload") assert output.url == "http://example.com/upload" def test_missing_url_raises(self): with pytest.raises(ValidationError): VideoUploadURLOutput() ================================================ FILE: agent/tests/unit_test/api/test_video_upload_url_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for video_upload_url inner function via generator invocation.""" from unittest.mock import MagicMock import pytest from vss_agents.api.video_upload_url import VideoUploadURLConfig from vss_agents.api.video_upload_url import VideoUploadURLInput from vss_agents.api.video_upload_url import VideoUploadURLOutput from vss_agents.api.video_upload_url import video_upload_url class TestVideoUploadUrlInner: """Test the inner _video_upload_url function.""" @pytest.fixture def config(self): return VideoUploadURLConfig( vst_external_url="http://1.2.3.4:30888", agent_base_url="http://10.0.0.1:8000", ) @pytest.fixture def mock_builder(self): return MagicMock() @pytest.mark.asyncio async def test_upload_url_normal(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = VideoUploadURLInput(filename="test_video.mp4") result = await inner_fn(inp) assert isinstance(result, VideoUploadURLOutput) assert "1.2.3.4:30888" in result.url assert "test_video" in result.url assert "2025-01-01T00:00:00.000Z" in result.url @pytest.mark.asyncio async def test_upload_url_embedding(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = VideoUploadURLInput(filename="test_video.mp4", embedding=True) result = await inner_fn(inp) assert isinstance(result, VideoUploadURLOutput) assert "10.0.0.1:8000" in result.url assert "videos-for-search" in result.url assert "test_video" in result.url @pytest.mark.asyncio async def test_upload_url_filename_without_extension(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = VideoUploadURLInput(filename="my_video") result = await inner_fn(inp) assert isinstance(result, VideoUploadURLOutput) assert "my_video" in result.url @pytest.mark.asyncio async def test_upload_url_trailing_slash_stripped(self, mock_builder): config = VideoUploadURLConfig( vst_external_url="http://1.2.3.4:30888/", agent_base_url="http://10.0.0.1:8000/", ) gen = video_upload_url.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = VideoUploadURLInput(filename="test.mp4") result = await inner_fn(inp) assert "//" not in result.url.replace("http://", "") @pytest.mark.asyncio async def test_converters_registered(self, config, mock_builder): gen = video_upload_url.__wrapped__(config, mock_builder) function_info = await gen.__anext__() assert function_info is not None assert function_info.converters is not None assert len(function_info.converters) > 0 ================================================ FILE: agent/tests/unit_test/conftest.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Common pytest fixtures for unit tests.""" from datetime import UTC from datetime import datetime from unittest.mock import AsyncMock from unittest.mock import MagicMock import pytest @pytest.fixture def mock_llm(): """Create a mock LLM object for testing.""" llm = MagicMock() llm.model_name = "test-model" return llm @pytest.fixture def mock_llm_response(): """Create a mock LLM response object.""" response = MagicMock() response.content = "Test content" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} return response @pytest.fixture def sample_video_event(): """Create a sample video event for testing.""" return {"start_timestamp": 10.5, "end_timestamp": 25.0, "event_description": "A person walking across the street"} @pytest.fixture def sample_incidents(): """Create sample incident data for testing.""" return [ { "Id": "incident-001", "sensorId": "sensor-001", "timestamp": "2025-01-15T10:00:00.000Z", "end": "2025-01-15T10:05:00.000Z", "category": "traffic_violation", "type": "mdx-incidents", "isAnomaly": False, }, { "Id": "incident-002", "sensorId": "sensor-001", "timestamp": "2025-01-15T10:10:00.000Z", "end": "2025-01-15T10:15:00.000Z", "category": "jaywalking", "type": "mdx-incidents", "isAnomaly": True, }, ] @pytest.fixture def sample_sensors(): """Create sample sensor data for testing.""" return [ { "id": "sensor-001", "place": [ {"value": "San Jose"}, {"value": "Intersection_A"}, ], }, { "id": "sensor-002", "place": [ {"value": "San Jose"}, {"value": "Intersection_B"}, ], }, { "id": "sensor-003", "place": [ {"value": "Mountain View"}, {"value": "Intersection_C"}, ], }, ] @pytest.fixture def sample_markdown_report(): """Create a sample markdown report for testing.""" return """# Test Report ## Summary | Field | Value | |-------|-------| | Location | San Jose | | Time | 10:00 AM | ## Details ### Incident Information | Field | Value | |-------|-------| | Type | Traffic Violation | | Duration | 5 minutes | **Incident Snapshot:** [View](http://example.com/snapshot.jpg) **Incident Video:** [View](http://example.com/video.mp4) """ @pytest.fixture def sample_geocoding_response(): """Create a sample geocoding response for testing.""" return { "features": [ { "properties": { "geocoding": { "type": "street", "city": "San Jose", "county": "Santa Clara County", "state": "California", "country": "United States", "name": "Main Street", "label": "123 Main Street, San Jose, CA", "osm_key": "highway", "osm_value": "residential", "extra": {"maxspeed": "35"}, } } } ] } @pytest.fixture def mock_async_http_response(): """Create a mock async HTTP response.""" response = AsyncMock() response.status = 200 return response @pytest.fixture def utc_now(): """Get current UTC time.""" return datetime.now(UTC) ================================================ FILE: agent/tests/unit_test/data_models/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vss_agents.data_models package.""" ================================================ FILE: agent/tests/unit_test/data_models/test_vss.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/data_models/vss.py.""" from datetime import UTC from datetime import datetime import pytest from vss_agents.data_models.vss import MediaInfoOffset from vss_agents.data_models.vss import float_to_int from vss_agents.data_models.vss import remove_timezone from vss_agents.data_models.vss import timestamp_validator class TestFloatToInt: """Tests for float_to_int function.""" def test_float_to_int_positive(self): """Test converting positive float to int (ceil).""" assert float_to_int(1.1) == 2 assert float_to_int(1.9) == 2 assert float_to_int(1.0) == 1 def test_float_to_int_zero(self): """Test converting zero.""" assert float_to_int(0.0) == 0 def test_float_to_int_already_int(self): """Test converting integer value.""" assert float_to_int(5) == 5 def test_float_to_int_none(self): """Test converting None returns None.""" assert float_to_int(None) is None def test_float_to_int_large(self): """Test converting large float.""" assert float_to_int(999.1) == 1000 class TestTimestampValidator: """Tests for timestamp_validator function.""" def test_valid_rfc3339_timestamp(self): """Test valid RFC3339 timestamp.""" # Create a mock validation_info class MockValidationInfo: field_name = "timestamp" result = timestamp_validator("2024-01-15T10:30:45.123Z", MockValidationInfo()) assert result == "2024-01-15T10:30:45.123Z" def test_invalid_timestamp_format(self): """Test that timestamp_validator raises ValueError for malformed timestamp strings.""" class MockValidationInfo: field_name = "timestamp" with pytest.raises(ValueError): timestamp_validator("2024-01-15", MockValidationInfo()) def test_invalid_timestamp_values(self): """Test invalid timestamp values.""" class MockValidationInfo: field_name = "timestamp" with pytest.raises(ValueError): timestamp_validator("2024-13-45T99:99:99.999Z", MockValidationInfo()) class TestRemoveTimezone: """Tests for remove_timezone function.""" def test_remove_timezone_from_z_suffix(self): """Test removing timezone from Z suffix string.""" result = remove_timezone("2024-01-15T10:30:45.123456Z") assert result.tzinfo is None assert result.year == 2024 assert result.month == 1 assert result.day == 15 def test_remove_timezone_from_offset(self): """Test removing timezone from offset string.""" result = remove_timezone("2024-01-15T10:30:45+05:00") assert result.tzinfo is None def test_remove_timezone_from_datetime(self): """Test removing timezone from datetime object.""" dt = datetime(2024, 1, 15, 10, 30, 45, tzinfo=UTC) result = remove_timezone(dt) assert result.tzinfo is None assert result.year == 2024 def test_remove_timezone_naive_datetime(self): """Test with naive datetime (no timezone).""" dt = datetime(2024, 1, 15, 10, 30, 45) result = remove_timezone(dt) assert result.tzinfo is None assert result == dt def test_remove_timezone_invalid_string(self): """Test with invalid string raises ValueError.""" with pytest.raises(ValueError): remove_timezone("not-a-timestamp") def test_remove_timezone_invalid_type(self): """Test with invalid type raises TypeError.""" with pytest.raises(TypeError): remove_timezone(12345) def test_remove_timezone_without_microseconds(self): """Test timestamp without microseconds.""" result = remove_timezone("2024-01-15T10:30:45Z") assert result.year == 2024 class TestMediaInfoOffset: """Tests for MediaInfoOffset model.""" def test_create_media_info_offset(self): """Test creating MediaInfoOffset.""" info = MediaInfoOffset(start_offset=10, end_offset=60) assert info.type == "offset" assert info.start_offset == 10 assert info.end_offset == 60 def test_media_info_offset_defaults(self): """Test MediaInfoOffset with default values.""" info = MediaInfoOffset() assert info.start_offset == 0 assert info.end_offset == 4000000000 def test_media_info_offset_float_conversion(self): """Test MediaInfoOffset converts floats to ints (ceil).""" info = MediaInfoOffset(start_offset=10.5, end_offset=59.1) assert info.start_offset == 11 assert info.end_offset == 60 def test_media_info_offset_none_to_default(self): """Test MediaInfoOffset converts None to default values.""" info = MediaInfoOffset(start_offset=None, end_offset=None) assert info.start_offset == 0 assert info.end_offset == 4000000000 def test_media_info_offset_alias_start(self): """Test MediaInfoOffset with start_offset field.""" # Note: Aliases work for field names in validation, not as kwargs # The model uses start_offset and end_offset as primary field names data = {"start_offset": 10, "end_offset": 60} info = MediaInfoOffset(**data) assert info.start_offset == 10 assert info.end_offset == 60 def test_media_info_offset_type_literal(self): """Test MediaInfoOffset type is always 'offset'.""" info = MediaInfoOffset(start_offset=0, end_offset=100) assert info.type == "offset" def test_media_info_offset_large_values(self): """Test MediaInfoOffset with large offset values.""" info = MediaInfoOffset(start_offset=0, end_offset=4000000000) assert info.end_offset == 4000000000 def test_media_info_offset_forbid_extra(self): """Test MediaInfoOffset forbids extra fields.""" with pytest.raises(Exception): # Pydantic validation error MediaInfoOffset(start_offset=0, end_offset=100, extra_field="invalid") ================================================ FILE: agent/tests/unit_test/embed/test_cosmos_embed.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for cosmos_embed module.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import httpx import pytest from vss_agents.embed.cosmos_embed import CosmosEmbedClient class TestCosmosEmbedClient: """Test CosmosEmbedClient class.""" def test_init(self): client = CosmosEmbedClient("http://localhost:8080") assert client.endpoint == "http://localhost:8080" assert client.text_embeddings_url == "http://localhost:8080/v1/generate_text_embeddings" assert client.image_embeddings_url == "http://localhost:8080/v1/generate_image_embeddings" assert client.video_embeddings_url == "http://localhost:8080/v1/generate_video_embeddings" def test_init_with_trailing_slash(self): # Test that URLs are constructed correctly even with trailing slash client = CosmosEmbedClient("http://localhost:8080/") # Note: the current implementation doesn't strip trailing slash assert client.endpoint == "http://localhost:8080/" class TestGetImageEmbedding: """Test get_image_embedding method.""" @pytest.fixture def client(self): return CosmosEmbedClient("http://localhost:8080") @pytest.mark.asyncio async def test_get_image_embedding_base64(self, client): with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_response = MagicMock() mock_response.json.return_value = {"data": [{"embedding": [0.1, 0.2, 0.3]}]} mock_client.post.return_value = mock_response result = await client.get_image_embedding("data:image/jpeg;base64,abc123") assert result == [0.1, 0.2, 0.3] mock_client.post.assert_called_once() @pytest.mark.asyncio async def test_get_image_embedding_url(self, client): with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_response = MagicMock() mock_response.json.return_value = {"data": [{"embedding": [0.4, 0.5, 0.6]}]} mock_client.post.return_value = mock_response result = await client.get_image_embedding("http://example.com/image.jpg") assert result == [0.4, 0.5, 0.6] # Check that presigned_url format was used call_args = mock_client.post.call_args payload = call_args[1]["json"] assert "presigned_url" in payload["input"][0] @pytest.mark.asyncio async def test_get_image_embedding_http_error(self, client): with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_client.post.side_effect = httpx.HTTPError("Connection failed") with pytest.raises(httpx.HTTPError): await client.get_image_embedding("http://example.com/image.jpg") class TestGetTextEmbedding: """Test get_text_embedding method.""" @pytest.fixture def client(self): return CosmosEmbedClient("http://localhost:8080") @pytest.mark.asyncio async def test_get_text_embedding_success(self, client): with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_response = MagicMock() mock_response.json.return_value = {"data": [{"embeddings": [0.7, 0.8, 0.9]}]} mock_client.post.return_value = mock_response result = await client.get_text_embedding("hello world") assert result == [0.7, 0.8, 0.9] call_args = mock_client.post.call_args payload = call_args[1]["json"] assert payload["text_input"] == ["hello world"] @pytest.mark.asyncio async def test_get_text_embedding_http_error(self, client): with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_client.post.side_effect = httpx.HTTPError("Connection failed") with pytest.raises(httpx.HTTPError): await client.get_text_embedding("test text") class TestGetVideoEmbedding: """Test get_video_embedding method.""" @pytest.fixture def client(self): return CosmosEmbedClient("http://localhost:8080") @pytest.mark.asyncio async def test_get_video_embedding_success(self, client): with patch.object(client, "get_video_embeddings_from_urls") as mock_get: mock_get.return_value = [[0.1, 0.2, 0.3]] result = await client.get_video_embedding("http://example.com/video.mp4") assert result == [0.1, 0.2, 0.3] mock_get.assert_called_once_with(["http://example.com/video.mp4"]) class TestGetVideoEmbeddingsFromUrls: """Test get_video_embeddings_from_urls method.""" @pytest.fixture def client(self): return CosmosEmbedClient("http://localhost:8080") @pytest.mark.asyncio async def test_get_video_embeddings_single_url(self, client): with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_response = MagicMock() mock_response.json.return_value = {"data": [{"embedding": [0.1, 0.2, 0.3]}]} mock_client.post.return_value = mock_response result = await client.get_video_embeddings_from_urls(["http://example.com/video.mp4"]) assert result == [[0.1, 0.2, 0.3]] call_args = mock_client.post.call_args payload = call_args[1]["json"] assert "presigned_url" in payload["input"][0] assert payload["request_type"] == "bulk_video" @pytest.mark.asyncio async def test_get_video_embeddings_multiple_urls(self, client): with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_response = MagicMock() mock_response.json.return_value = { "data": [ {"embedding": [0.1, 0.2, 0.3]}, {"embedding": [0.4, 0.5, 0.6]}, ] } mock_client.post.return_value = mock_response result = await client.get_video_embeddings_from_urls( [ "http://example.com/video1.mp4", "http://example.com/video2.mp4", ] ) assert len(result) == 2 assert result[0] == [0.1, 0.2, 0.3] assert result[1] == [0.4, 0.5, 0.6] @pytest.mark.asyncio async def test_get_video_embeddings_url_formatting(self, client): with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client_class.return_value.__aenter__.return_value = mock_client mock_response = MagicMock() mock_response.json.return_value = {"data": [{"embedding": [0.1]}]} mock_client.post.return_value = mock_response await client.get_video_embeddings_from_urls(["http://test.com/video.mp4"]) call_args = mock_client.post.call_args payload = call_args[1]["json"] # Check URL formatting assert payload["input"][0] == "data:video/mp4;presigned_url,http://test.com/video.mp4" assert payload["model"] == "nvidia/cosmos-embed1" assert payload["encoding_format"] == "float" ================================================ FILE: agent/tests/unit_test/evaluators/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vss_agents.evaluators package.""" ================================================ FILE: agent/tests/unit_test/evaluators/test_custom_qa.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for customized_qa_evaluator/evaluate module.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from nat.eval.evaluator.evaluator_model import EvalInputItem import pytest from vss_agents.evaluators.customized_qa_evaluator.evaluate import DEFAULT_QA_EVAL_PROMPT from vss_agents.evaluators.customized_qa_evaluator.evaluate import CustomizedQAEvaluator class TestDefaultQAEvalPrompt: """Test DEFAULT_QA_EVAL_PROMPT constant.""" def test_prompt_exists(self): assert DEFAULT_QA_EVAL_PROMPT is not None def test_prompt_has_input_variables(self): assert "question" in DEFAULT_QA_EVAL_PROMPT.input_variables assert "answer" in DEFAULT_QA_EVAL_PROMPT.input_variables assert "reference" in DEFAULT_QA_EVAL_PROMPT.input_variables def test_prompt_template_content(self): assert "evaluator" in DEFAULT_QA_EVAL_PROMPT.template.lower() assert "score" in DEFAULT_QA_EVAL_PROMPT.template.lower() class TestCustomizedQAEvaluator: """Test CustomizedQAEvaluator class.""" def test_init_default_prompt(self): mock_llm = MagicMock() evaluator = CustomizedQAEvaluator(llm=mock_llm) assert evaluator.llm is mock_llm assert evaluator.max_retries == 2 assert evaluator.evaluation_method_id == "qa" assert evaluator.llm_judge_reasoning is True assert evaluator.eval_prompt is DEFAULT_QA_EVAL_PROMPT def test_init_custom_prompt(self): from langchain_core.prompts import PromptTemplate mock_llm = MagicMock() custom_prompt = PromptTemplate( input_variables=["question", "answer", "reference"], template="Custom: {question} {answer} {reference}", ) evaluator = CustomizedQAEvaluator(llm=mock_llm, custom_prompt=custom_prompt) assert evaluator.eval_prompt is custom_prompt def test_init_custom_params(self): mock_llm = MagicMock() evaluator = CustomizedQAEvaluator( llm=mock_llm, max_concurrency=16, max_retries=5, evaluation_method_id="custom_qa", llm_judge_reasoning=False, ) assert evaluator.max_retries == 5 assert evaluator.evaluation_method_id == "custom_qa" assert evaluator.llm_judge_reasoning is False @pytest.mark.asyncio async def test_evaluate_item_skips_wrong_method(self): mock_llm = MagicMock() evaluator = CustomizedQAEvaluator(llm=mock_llm, evaluation_method_id="qa") item = EvalInputItem( id="test_001", input_obj="What color is the truck?", output_obj="The truck is red.", expected_output_obj="Red", full_dataset_entry={"evaluation_method": ["trajectory"]}, # Not "qa" ) result = await evaluator.evaluate_item(item) assert result.id == "test_001" assert result.score is None assert "Skipped" in result.reasoning @pytest.mark.asyncio async def test_evaluate_item_missing_ground_truth(self): mock_llm = MagicMock() evaluator = CustomizedQAEvaluator(llm=mock_llm, evaluation_method_id="qa") item = EvalInputItem( id="test_002", input_obj="What color is the truck?", output_obj="The truck is red.", expected_output_obj="", # Empty ground truth full_dataset_entry={"evaluation_method": ["qa"]}, ) result = await evaluator.evaluate_item(item) assert result.id == "test_002" assert result.score == 0.0 assert "no ground_truth" in result.reasoning @pytest.mark.asyncio async def test_evaluate_item_success(self): mock_response = MagicMock() mock_response.content = "0.85" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} mock_llm = AsyncMock() mock_llm.model_name = "test-model" mock_llm.ainvoke = AsyncMock(return_value=mock_response) mock_llm.bind = MagicMock(return_value=mock_llm) evaluator = CustomizedQAEvaluator(llm=mock_llm, evaluation_method_id="qa") item = EvalInputItem( id="test_003", input_obj="What color is the truck?", output_obj="The truck is red.", expected_output_obj="Red", full_dataset_entry={"evaluation_method": ["qa"]}, ) result = await evaluator.evaluate_item(item) assert result.id == "test_003" assert result.score == 0.85 mock_llm.ainvoke.assert_called_once() @pytest.mark.asyncio async def test_evaluate_item_strips_agent_think_tags(self): mock_response = MagicMock() mock_response.content = "0.9" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} mock_llm = AsyncMock() mock_llm.model_name = "test-model" mock_llm.ainvoke = AsyncMock(return_value=mock_response) mock_llm.bind = MagicMock(return_value=mock_llm) evaluator = CustomizedQAEvaluator(llm=mock_llm, evaluation_method_id="qa") # Answer with agent-think tags that should be stripped item = EvalInputItem( id="test_004", input_obj="What color is the truck?", output_obj="Let me think...The truck is red.", expected_output_obj="Red", full_dataset_entry={"evaluation_method": ["qa"]}, ) result = await evaluator.evaluate_item(item) assert result.id == "test_004" assert result.score == 0.9 ================================================ FILE: agent/tests/unit_test/evaluators/test_custom_trajectory.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for customized_trajectory_evaluator/evaluate module.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch from langchain_core.exceptions import OutputParserException from nat.eval.evaluator.evaluator_model import EvalInputItem import pytest from vss_agents.evaluators.customized_trajectory_evaluator.evaluate import CustomizedTrajectoryEvaluator from vss_agents.evaluators.utils import ScoreOutputParser class TestScoreOutputParser: """Test ScoreOutputParser class.""" def test_parse_simple_score(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "0.85" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} result = parser.parse(mock_response) assert result["score"] == 0.85 assert result["reasoning"] == "" def test_parse_with_thinking(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "My reasoning here.0.75" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} result = parser.parse(mock_response) assert result["score"] == 0.75 assert result["reasoning"] == "My reasoning here." def test_parse_with_reasoning_content_attribute(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "0.9" mock_response.reasoning_content = "This is detailed reasoning" mock_response.additional_kwargs = {} mock_response.response_metadata = {} result = parser.parse(mock_response) assert result["score"] == 0.9 assert "reasoning" in result["reasoning"] def test_parse_score_zero(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "0.0" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} result = parser.parse(mock_response) assert result["score"] == 0.0 def test_parse_score_one(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "1.0" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} result = parser.parse(mock_response) assert result["score"] == 1.0 def test_parse_no_score_raises_error(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "no numbers here" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} with pytest.raises(OutputParserException): parser.parse(mock_response) def test_parse_score_out_of_range_raises_error(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "1.5" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} with pytest.raises(OutputParserException): parser.parse(mock_response) class TestCustomizedTrajectoryEvaluatorInit: """Test CustomizedTrajectoryEvaluator constructor.""" def test_init_with_dual_prompts(self): mock_llm = MagicMock() mock_prompt_ref = MagicMock() mock_prompt_noref = MagicMock() evaluator = CustomizedTrajectoryEvaluator( llm=mock_llm, tools=None, prompt_with_reference=mock_prompt_ref, prompt_without_reference=mock_prompt_noref, ) assert evaluator.prompt_with_reference is mock_prompt_ref assert evaluator.prompt_without_reference is mock_prompt_noref def test_init_defaults_to_none_prompts(self): mock_llm = MagicMock() evaluator = CustomizedTrajectoryEvaluator(llm=mock_llm, tools=None) assert evaluator.prompt_with_reference is None assert evaluator.prompt_without_reference is None class TestExtractToolCallsFromLlmEnd: """Test _extract_tool_calls_from_llm_end with data.output parsing.""" @pytest.fixture def evaluator(self): mock_llm = MagicMock() return CustomizedTrajectoryEvaluator(llm=mock_llm, tools=None) def test_parses_tool_calls_from_data_output(self, evaluator): step = MagicMock() step.data = MagicMock() step.data.output = ( "\n\nTool calls: [{'id': 'call-1', 'type': 'function', " "'function': {'name': 'tool_a', 'arguments': '{\"param_1\": \"value_1\"}'}}]" ) result = evaluator._extract_tool_calls_from_llm_end(step) assert len(result) == 1 assert result[0]["function"]["name"] == "tool_a" def test_parses_openai_format_tool_calls(self, evaluator): step = MagicMock() step.data = MagicMock() step.data.output = "\n\nTool calls: [{'name': 'tool_a', 'args': {'param_1': 'value_1'}}]" result = evaluator._extract_tool_calls_from_llm_end(step) assert len(result) == 1 assert result[0]["name"] == "tool_a" def test_parses_multiple_tool_calls(self, evaluator): step = MagicMock() step.data = MagicMock() step.data.output = ( "\n\nTool calls: [" "{'name': 'tool_a', 'args': {'param_1': 'value_1'}}, " "{'name': 'tool_b', 'args': {'param_2': 'value_2'}}]" ) result = evaluator._extract_tool_calls_from_llm_end(step) assert len(result) == 2 def test_returns_empty_for_no_data(self, evaluator): step = MagicMock(spec=[]) result = evaluator._extract_tool_calls_from_llm_end(step) assert result == [] def test_returns_empty_for_no_tool_calls_string(self, evaluator): step = MagicMock() step.data = MagicMock() step.data.output = "Some other output without tool calls" result = evaluator._extract_tool_calls_from_llm_end(step) assert result == [] def test_returns_empty_for_malformed_tool_calls(self, evaluator): step = MagicMock() step.data = MagicMock() step.data.output = "\n\nTool calls: not-valid-python" result = evaluator._extract_tool_calls_from_llm_end(step) assert result == [] class TestGetAgentSelectedUuids: """Test _get_agent_selected_uuids method.""" @pytest.fixture def evaluator(self): """Create a CustomizedTrajectoryEvaluator instance for testing.""" mock_llm = MagicMock() return CustomizedTrajectoryEvaluator(llm=mock_llm, tools=None) def _create_mock_step(self, event_type, uuid, parent_id, payload_name=None, tool_calls_output=None): """Helper to create mock trajectory steps using the new data.output format.""" step = MagicMock() step.event_type = event_type step.UUID = uuid step.parent_id = parent_id step.payload = MagicMock() step.payload.name = payload_name if tool_calls_output: step.data = MagicMock() step.data.output = f"\n\nTool calls: {tool_calls_output}" else: step.data = MagicMock() step.data.output = "" return step def test_returns_llm_end_that_made_tool_selection(self, evaluator): """Test that LLM_END events that made tool selections are included.""" from nat.data_models.intermediate_step import IntermediateStepType llm_uuid = "llm-uuid-1" tool_uuid = "tool-uuid-1" parent_id = "parent-1" tool_calls_str = "[{'function': {'name': 'search_tool'}}]" trajectory = [ self._create_mock_step(IntermediateStepType.LLM_END, llm_uuid, parent_id, tool_calls_output=tool_calls_str), self._create_mock_step(IntermediateStepType.TOOL_END, tool_uuid, parent_id, payload_name="search_tool"), ] result = evaluator._get_agent_selected_uuids(trajectory) assert llm_uuid in result, "LLM_END that made tool selection should be included" assert tool_uuid in result, "TOOL_END that was selected should be included" def test_excludes_llm_end_without_tool_calls(self, evaluator): """Test that LLM_END events without tool calls are not included.""" from nat.data_models.intermediate_step import IntermediateStepType llm_uuid = "llm-uuid-internal" parent_id = "parent-1" # LLM_END without tool_calls trajectory = [ self._create_mock_step(IntermediateStepType.LLM_END, llm_uuid, parent_id), ] result = evaluator._get_agent_selected_uuids(trajectory) assert llm_uuid not in result, "LLM_END without tool calls should not be included" def test_multiple_tool_selections(self, evaluator): """Test that multiple tool selections are all included.""" from nat.data_models.intermediate_step import IntermediateStepType llm_uuid = "llm-uuid" tool1_uuid = "tool1-uuid" tool2_uuid = "tool2-uuid" parent_id = "parent-1" tool_calls_str = "[{'function': {'name': 'tool_a'}}, {'function': {'name': 'tool_b'}}]" trajectory = [ self._create_mock_step(IntermediateStepType.LLM_END, llm_uuid, parent_id, tool_calls_output=tool_calls_str), self._create_mock_step(IntermediateStepType.TOOL_END, tool1_uuid, parent_id, payload_name="tool_a"), self._create_mock_step(IntermediateStepType.TOOL_END, tool2_uuid, parent_id, payload_name="tool_b"), ] result = evaluator._get_agent_selected_uuids(trajectory) assert llm_uuid in result assert tool1_uuid in result assert tool2_uuid in result def test_nested_tool_calls_filtered(self, evaluator): """Test that nested tool calls (tools called by tools) are filtered out.""" from nat.data_models.intermediate_step import IntermediateStepType agent_llm_uuid = "agent-llm-uuid" outer_tool_uuid = "outer-tool-uuid" nested_tool_uuid = "nested-tool-uuid" agent_parent_id = "agent-parent" outer_tool_parent_id = "outer-tool-parent" # Different parent for nested calls tool_calls_str = "[{'function': {'name': 'outer_tool'}}]" nested_llm_uuid = "nested-llm-uuid" final_llm_uuid = "final-llm-uuid" trajectory = [ # Agent's LLM selecting outer_tool self._create_mock_step( IntermediateStepType.LLM_END, agent_llm_uuid, agent_parent_id, tool_calls_output=tool_calls_str ), # Nested LLM call self._create_mock_step(IntermediateStepType.LLM_END, nested_llm_uuid, outer_tool_parent_id), # Nested tool call self._create_mock_step( IntermediateStepType.TOOL_END, nested_tool_uuid, outer_tool_parent_id, payload_name="nested_tool" ), # The outer tool result self._create_mock_step( IntermediateStepType.TOOL_END, outer_tool_uuid, agent_parent_id, payload_name="outer_tool" ), # Agent's final LLM response (no tool_calls) self._create_mock_step(IntermediateStepType.LLM_END, final_llm_uuid, agent_parent_id), ] result = evaluator._get_agent_selected_uuids(trajectory) assert agent_llm_uuid in result, "Agent's LLM with tool_calls should be included" assert outer_tool_uuid in result, "Agent-selected outer tool should be included" assert nested_llm_uuid not in result, "Nested LLM call should not be included" assert nested_tool_uuid not in result, "Nested tool call should not be included" assert final_llm_uuid not in result, "Agent's LLM without tool_calls should not be included" def test_tool_name_must_match(self, evaluator): """Test that TOOL_END is only matched when tool name matches the tool_call.""" from nat.data_models.intermediate_step import IntermediateStepType llm_uuid = "llm-uuid" matching_tool_uuid = "matching-tool-uuid" non_matching_tool_uuid = "non-matching-tool-uuid" parent_id = "parent-1" tool_calls_str = "[{'function': {'name': 'expected_tool'}}]" trajectory = [ self._create_mock_step(IntermediateStepType.LLM_END, llm_uuid, parent_id, tool_calls_output=tool_calls_str), # Tool with wrong name: should not be matched self._create_mock_step( IntermediateStepType.TOOL_END, non_matching_tool_uuid, parent_id, payload_name="wrong_tool" ), # Tool with correct name: should be matched self._create_mock_step( IntermediateStepType.TOOL_END, matching_tool_uuid, parent_id, payload_name="expected_tool" ), ] result = evaluator._get_agent_selected_uuids(trajectory) assert llm_uuid in result assert matching_tool_uuid in result, "Tool with matching name should be included" assert non_matching_tool_uuid not in result, "Tool with non-matching name should not be included" def test_tool_matching_respects_order(self, evaluator): """Test that tools are matched in order after LLM_END.""" from nat.data_models.intermediate_step import IntermediateStepType llm_uuid = "llm-uuid" first_tool_uuid = "first-tool-uuid" second_tool_uuid = "second-tool-uuid" parent_id = "parent-1" # LLM calls same tool twice tool_calls_str = "[{'function': {'name': 'repeated_tool'}}, {'function': {'name': 'repeated_tool'}}]" trajectory = [ self._create_mock_step(IntermediateStepType.LLM_END, llm_uuid, parent_id, tool_calls_output=tool_calls_str), self._create_mock_step( IntermediateStepType.TOOL_END, first_tool_uuid, parent_id, payload_name="repeated_tool" ), self._create_mock_step( IntermediateStepType.TOOL_END, second_tool_uuid, parent_id, payload_name="repeated_tool" ), ] result = evaluator._get_agent_selected_uuids(trajectory) assert llm_uuid in result assert first_tool_uuid in result, "First matching tool should be included" assert second_tool_uuid in result, "Second matching tool should also be included" def test_openai_format_tool_name(self, evaluator): """Test that OpenAI format tool names ({"name": "..."}) are matched.""" from nat.data_models.intermediate_step import IntermediateStepType llm_uuid = "llm-uuid" tool_uuid = "tool-uuid" parent_id = "parent-1" tool_calls_str = "[{'name': 'my_tool', 'args': {'key': 'value'}}]" trajectory = [ self._create_mock_step(IntermediateStepType.LLM_END, llm_uuid, parent_id, tool_calls_output=tool_calls_str), self._create_mock_step(IntermediateStepType.TOOL_END, tool_uuid, parent_id, payload_name="my_tool"), ] result = evaluator._get_agent_selected_uuids(trajectory) assert llm_uuid in result assert tool_uuid in result, "Tool matched via OpenAI format name should be included" _EVAL_MODULE = "vss_agents.evaluators.customized_trajectory_evaluator.evaluate" _ADAPTER_CLASS = "nat.eval.intermediate_step_adapter.IntermediateStepAdapter" class TestEvaluateItem: """Test evaluate_item method: prompt selection, structured tool calls, conversation history.""" def _make_evaluator(self, prompt_with_ref=None, prompt_without_ref=None): return CustomizedTrajectoryEvaluator( llm=MagicMock(), tools=None, prompt_with_reference=prompt_with_ref, prompt_without_reference=prompt_without_ref, ) def _make_item(self, item_id="test_001", query="What?", output="Answer", full_dataset_entry=None): item = EvalInputItem( id=item_id, input_obj=query, output_obj=output, expected_output_obj=None, full_dataset_entry=full_dataset_entry or {"evaluation_method": ["trajectory"]}, ) item.trajectory = [] return item def _make_agent_action(self, tool_name, tool_input): """Create a mock AgentAction as returned by IntermediateStepAdapter.get_agent_actions.""" action = MagicMock() action.tool = tool_name action.tool_input = tool_input action.model_dump.return_value = {"tool": tool_name, "tool_input": tool_input, "log": ""} return action # --- Prompt selection --- @pytest.mark.asyncio @patch(f"{_EVAL_MODULE}.invoke_llm_with_retry", new_callable=AsyncMock) @patch(_ADAPTER_CLASS) async def test_uses_prompt_with_reference(self, mock_adapter, mock_invoke): """When item has trajectory_ground_truth, uses prompt_with_reference.""" mock_prompt = MagicMock() mock_prompt.format.return_value = "formatted prompt" evaluator = self._make_evaluator(prompt_with_ref=mock_prompt) mock_adapter.return_value.get_agent_actions.return_value = [] mock_invoke.return_value = MagicMock(id="test_001", score=0.8) item = self._make_item( full_dataset_entry={ "evaluation_method": ["trajectory"], "trajectory_ground_truth": [{"step": 1, "name": "tool_a"}], } ) await evaluator.evaluate_item(item) mock_prompt.format.assert_called_once() fmt_kwargs = mock_prompt.format.call_args.kwargs assert "reference" in fmt_kwargs assert "question" in fmt_kwargs assert "agent_trajectory" in fmt_kwargs assert "answer" in fmt_kwargs @pytest.mark.asyncio @patch(f"{_EVAL_MODULE}.invoke_llm_with_retry", new_callable=AsyncMock) @patch(_ADAPTER_CLASS) async def test_uses_prompt_without_reference(self, mock_adapter, mock_invoke): """When item has no trajectory_ground_truth, uses prompt_without_reference.""" mock_prompt = MagicMock() mock_prompt.format.return_value = "formatted prompt" evaluator = self._make_evaluator(prompt_without_ref=mock_prompt) mock_adapter.return_value.get_agent_actions.return_value = [] mock_invoke.return_value = MagicMock(id="test_001", score=0.8) item = self._make_item(full_dataset_entry={"evaluation_method": ["trajectory"]}) await evaluator.evaluate_item(item) mock_prompt.format.assert_called_once() fmt_kwargs = mock_prompt.format.call_args.kwargs assert "conversation_history" in fmt_kwargs assert "tool_schemas" in fmt_kwargs assert "question" in fmt_kwargs @pytest.mark.asyncio @patch(_ADAPTER_CLASS) async def test_raises_when_reference_but_no_prompt(self, mock_adapter): """ValueError when item has trajectory_ground_truth but prompt_with_reference is not configured.""" evaluator = self._make_evaluator() mock_adapter.return_value.get_agent_actions.return_value = [] item = self._make_item( full_dataset_entry={ "evaluation_method": ["trajectory"], "trajectory_ground_truth": [{"step": 1, "name": "tool_a"}], } ) with pytest.raises(ValueError, match="custom_prompt_template_with_reference"): await evaluator.evaluate_item(item) @pytest.mark.asyncio @patch(_ADAPTER_CLASS) async def test_raises_when_no_reference_and_no_prompt(self, mock_adapter): """ValueError when item has no trajectory_ground_truth and prompt_without_reference is not configured.""" evaluator = self._make_evaluator() mock_adapter.return_value.get_agent_actions.return_value = [] item = self._make_item(full_dataset_entry={"evaluation_method": ["trajectory"]}) with pytest.raises(ValueError, match="custom_prompt_template_without_reference"): await evaluator.evaluate_item(item) # --- Structured tool call extraction --- @pytest.mark.asyncio @patch(f"{_EVAL_MODULE}.invoke_llm_with_retry", new_callable=AsyncMock) @patch(_ADAPTER_CLASS) async def test_structured_tool_calls_step_numbering(self, mock_adapter, mock_invoke): """Parallel tool calls share a step number; new LLM step increments it.""" mock_prompt = MagicMock() mock_prompt.format.return_value = "prompt" evaluator = self._make_evaluator(prompt_with_ref=mock_prompt) action_tool_a = self._make_agent_action("tool_a", {"p1": "v1"}) action_tool_b = self._make_agent_action("tool_b", {"p2": "v2"}) action_tool_c = self._make_agent_action("tool_c", {"p3": "v3"}) mock_adapter.return_value.get_agent_actions.return_value = [ # LLM step 1: selects tool_a and tool_b in parallel (self._make_agent_action("", ""), "reasoning\n\nTool calls: [{'name': 'tool_a'}, {'name': 'tool_b'}]"), (action_tool_a, "result_a"), (action_tool_b, "result_b"), # LLM step 2: selects tool_c (self._make_agent_action("", ""), "more reasoning\n\nTool calls: [{'name': 'tool_c'}]"), (action_tool_c, "result_c"), ] mock_invoke.return_value = MagicMock(id="test_001", score=0.9) item = self._make_item( full_dataset_entry={ "evaluation_method": ["trajectory"], "trajectory_ground_truth": [{"step": 1, "name": "tool_a"}], } ) await evaluator.evaluate_item(item) build_reasoning = mock_invoke.call_args.kwargs["build_reasoning"] actual = build_reasoning({"reasoning": "r"})["actual_tool_calls"] assert len(actual) == 3 assert actual[0] == {"step": 1, "name": "tool_a", "params": {"p1": "v1"}} assert actual[1] == {"step": 1, "name": "tool_b", "params": {"p2": "v2"}} assert actual[2] == {"step": 2, "name": "tool_c", "params": {"p3": "v3"}} @pytest.mark.asyncio @patch(f"{_EVAL_MODULE}.invoke_llm_with_retry", new_callable=AsyncMock) @patch(_ADAPTER_CLASS) async def test_tool_with_no_preceding_llm_defaults_to_step_1(self, mock_adapter, mock_invoke): """Tool with no preceding LLM reasoning step gets default step number 1.""" mock_prompt = MagicMock() mock_prompt.format.return_value = "prompt" evaluator = self._make_evaluator(prompt_with_ref=mock_prompt) action_tool = self._make_agent_action("tool_a", {"p": "v"}) mock_adapter.return_value.get_agent_actions.return_value = [ (action_tool, "result"), ] mock_invoke.return_value = MagicMock(id="test_001", score=0.9) item = self._make_item( full_dataset_entry={ "evaluation_method": ["trajectory"], "trajectory_ground_truth": [{"step": 1, "name": "tool_a"}], } ) await evaluator.evaluate_item(item) build_reasoning = mock_invoke.call_args.kwargs["build_reasoning"] actual = build_reasoning({"reasoning": "r"})["actual_tool_calls"] assert actual[0]["step"] == 1 @pytest.mark.asyncio @patch(f"{_EVAL_MODULE}.invoke_llm_with_retry", new_callable=AsyncMock) @patch(_ADAPTER_CLASS) async def test_string_tool_input_is_parsed(self, mock_adapter, mock_invoke): """String tool_input is parsed via ast.literal_eval into a dict.""" mock_prompt = MagicMock() mock_prompt.format.return_value = "prompt" evaluator = self._make_evaluator(prompt_with_ref=mock_prompt) action_tool = self._make_agent_action("tool_a", "{'key': 'value'}") mock_adapter.return_value.get_agent_actions.return_value = [ (action_tool, "result"), ] mock_invoke.return_value = MagicMock(id="test_001", score=0.9) item = self._make_item( full_dataset_entry={ "evaluation_method": ["trajectory"], "trajectory_ground_truth": [{"step": 1, "name": "tool_a"}], } ) await evaluator.evaluate_item(item) build_reasoning = mock_invoke.call_args.kwargs["build_reasoning"] actual = build_reasoning({"reasoning": "r"})["actual_tool_calls"] assert actual[0]["params"] == {"key": "value"} # --- Conversation history --- @pytest.mark.asyncio @patch(f"{_EVAL_MODULE}.invoke_llm_with_retry", new_callable=AsyncMock) @patch(_ADAPTER_CLASS) async def test_conversation_history_formatted_in_prompt(self, mock_adapter, mock_invoke): """Conversation history from _conversation_history is formatted and passed to prompt.""" mock_prompt = MagicMock() mock_prompt.format.return_value = "prompt" evaluator = self._make_evaluator(prompt_without_ref=mock_prompt) mock_adapter.return_value.get_agent_actions.return_value = [] mock_invoke.return_value = MagicMock(id="test_001", score=0.8) item = self._make_item( full_dataset_entry={ "evaluation_method": ["trajectory"], "_conversation_history": [ {"turn_id": "turn_1", "query": "Hello", "answer": "Hi"}, {"turn_id": "turn_2", "query": "More?", "answer": "Sure"}, ], } ) await evaluator.evaluate_item(item) history_str = mock_prompt.format.call_args.kwargs["conversation_history"] assert "[turn_1] User: Hello" in history_str assert "[turn_1] Assistant: Hi" in history_str assert "[turn_2] User: More?" in history_str assert "[turn_2] Assistant: Sure" in history_str @pytest.mark.asyncio @patch(f"{_EVAL_MODULE}.invoke_llm_with_retry", new_callable=AsyncMock) @patch(_ADAPTER_CLASS) async def test_no_conversation_history_shows_placeholder(self, mock_adapter, mock_invoke): """Without _conversation_history, prompt receives a placeholder string.""" mock_prompt = MagicMock() mock_prompt.format.return_value = "prompt" evaluator = self._make_evaluator(prompt_without_ref=mock_prompt) mock_adapter.return_value.get_agent_actions.return_value = [] mock_invoke.return_value = MagicMock(id="test_001", score=0.8) item = self._make_item(full_dataset_entry={"evaluation_method": ["trajectory"]}) await evaluator.evaluate_item(item) assert mock_prompt.format.call_args.kwargs["conversation_history"] == "(no previous turns)" # --- build_reasoning output --- @pytest.mark.asyncio @patch(f"{_EVAL_MODULE}.invoke_llm_with_retry", new_callable=AsyncMock) @patch(_ADAPTER_CLASS) async def test_build_reasoning_includes_all_fields(self, mock_adapter, mock_invoke): """build_reasoning callback produces dict with all expected fields.""" mock_prompt = MagicMock() mock_prompt.format.return_value = "prompt" evaluator = self._make_evaluator(prompt_with_ref=mock_prompt) mock_adapter.return_value.get_agent_actions.return_value = [] mock_invoke.return_value = MagicMock(id="test_001", score=0.8) ground_truth = [{"step": 1, "name": "tool_a"}] conv_history = [{"turn_id": "t1", "query": "q", "answer": "a"}] item = self._make_item( query="What is X?", output="X is Y", full_dataset_entry={ "evaluation_method": ["trajectory"], "trajectory_ground_truth": ground_truth, "_conversation_history": conv_history, }, ) await evaluator.evaluate_item(item) build_reasoning = mock_invoke.call_args.kwargs["build_reasoning"] result = build_reasoning({"reasoning": "my reasoning"}) assert result["reasoning"] == "my reasoning" assert result["query"] == "What is X?" assert result["expected_tool_calls"] == ground_truth assert result["final_answer"] == "X is Y" assert isinstance(result["actual_tool_calls"], list) assert result["conversation_history"] == conv_history assert result["track_agent_selected_tools_only"] is False ================================================ FILE: agent/tests/unit_test/evaluators/test_data_models.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/evaluators/report_evaluator/data_models.py.""" from vss_agents.evaluators.report_evaluator.data_models import EvaluationScore class TestEvaluationScore: """Tests for EvaluationScore model.""" def test_create_evaluation_score(self): """Test creating an EvaluationScore.""" score = EvaluationScore( section_score=0.85, method="f1", actual_value="predicted text", reference_value="reference text", ) assert score.section_score == 0.85 assert score.method == "f1" assert score.actual_value == "predicted text" assert score.reference_value == "reference text" assert score.error is None assert score.field_scores == {} def test_evaluation_score_with_error(self): """Test EvaluationScore with error.""" score = EvaluationScore( section_score=None, method="llm_judge", error="Failed to evaluate", ) assert score.section_score is None assert score.error == "Failed to evaluate" def test_evaluation_score_with_field_scores(self): """Test EvaluationScore with nested field_scores.""" nested_score = EvaluationScore( section_score=0.9, method="exact_match", ) score = EvaluationScore( section_score=0.85, method="average", field_scores={"field1": nested_score}, ) assert "field1" in score.field_scores assert score.field_scores["field1"].section_score == 0.9 def test_evaluation_score_bounds(self): """Test EvaluationScore bounds (0.0 to 1.0).""" # Valid scores score_zero = EvaluationScore(section_score=0.0, method="test") score_one = EvaluationScore(section_score=1.0, method="test") assert score_zero.section_score == 0.0 assert score_one.section_score == 1.0 def test_evaluation_score_from_error(self): """Test EvaluationScore.from_error class method.""" score = EvaluationScore.from_error( error_message="Something went wrong", method="llm_judge", actual_value="actual", reference_value="reference", ) assert score.section_score is None assert score.error == "Something went wrong" assert score.method == "llm_judge" assert score.actual_value == "actual" assert score.reference_value == "reference" def test_evaluation_score_from_error_with_field_scores(self): """Test EvaluationScore.from_error with field_scores.""" nested = EvaluationScore(section_score=0.5, method="test") score = EvaluationScore.from_error( error_message="Partial failure", field_scores={"partial": nested}, ) assert score.field_scores["partial"].section_score == 0.5 def test_evaluation_score_from_error_defaults(self): """Test EvaluationScore.from_error with default values.""" score = EvaluationScore.from_error(error_message="Error") assert score.section_score is None assert score.method == "unknown" assert score.actual_value is None assert score.reference_value is None assert score.field_scores == {} def test_evaluation_score_optional_fields(self): """Test EvaluationScore optional fields.""" score = EvaluationScore( section_score=0.75, method="custom", ) assert score.actual_value is None assert score.reference_value is None assert score.error is None def test_evaluation_score_none_section_score(self): """Test EvaluationScore with None section_score.""" score = EvaluationScore( section_score=None, method="skipped", ) assert score.section_score is None ================================================ FILE: agent/tests/unit_test/evaluators/test_eval_config_models.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/evaluators/report_evaluator/eval_config_models.py.""" from pydantic import ValidationError import pytest from vss_agents.evaluators.report_evaluator.eval_config_models import EvalMetricsConfig from vss_agents.evaluators.report_evaluator.eval_config_models import FieldConfig class TestFieldConfig: """Tests for FieldConfig model.""" def test_create_field_config_defaults(self): """Test creating FieldConfig with defaults.""" config = FieldConfig() assert config.method is None assert config.fields is None assert config.allow_dynamic_field_discovery is False def test_create_field_config_with_method(self): """Test creating FieldConfig with method.""" config = FieldConfig(method="f1") assert config.method == "f1" def test_field_config_with_nested_fields(self): """Test FieldConfig with nested fields.""" config = FieldConfig( method="average", fields={ "field1": FieldConfig(method="exact_match"), "field2": FieldConfig(method="f1"), }, ) assert len(config.fields) == 2 assert config.fields["field1"].method == "exact_match" def test_field_config_dynamic_discovery(self): """Test FieldConfig with dynamic field discovery.""" config = FieldConfig( method="average", allow_dynamic_field_discovery=True, ) assert config.allow_dynamic_field_discovery is True def test_field_config_method_collection(self): """Test that methods are collected in _methods.""" config = FieldConfig(method="f1") assert "f1" in config._methods def test_field_config_nested_method_collection(self): """Test that nested methods are collected.""" config = FieldConfig( method="average", fields={ "a": FieldConfig(method="exact_match"), "b": FieldConfig(method="regex"), }, ) # Parent should collect child methods assert "exact_match" in config._methods or "regex" in config._methods def test_field_config_average_without_fields_error(self): """Test that average method without fields raises error.""" with pytest.raises(ValueError, match="average"): FieldConfig(method="average", allow_dynamic_field_discovery=False) def test_field_config_empty_fields_error(self): """Test that explicitly empty fields raises error.""" with pytest.raises(ValueError): FieldConfig(method="exact_match", fields={}) def test_field_config_forbid_extra(self): """Test that extra fields are forbidden.""" with pytest.raises(ValidationError): FieldConfig(method="f1", unknown_field="value") def test_default_method_is_llm_judge(self): """Test that llm_judge is added as default when no method specified.""" config = FieldConfig() assert config.method is None assert "llm_judge" in config._methods def test_methods_collected_from_nested_structure(self): """Test that methods are correctly collected from nested structure and are deduplicated.""" config = FieldConfig( method="average", fields={ "field1": FieldConfig(method="exact_match"), "field2": FieldConfig( method="average", fields={ "nested1": FieldConfig(method="f1"), "nested2": FieldConfig(method="llm_judge"), "nested3": FieldConfig(method="exact_match"), }, ), "field3": FieldConfig(method="exact_match"), }, ) assert config._methods == {"exact_match", "f1", "llm_judge"} @pytest.mark.parametrize( "invalid_config,expected_error", [ ({"method": "average"}, "Method 'average' can only be used for sections"), ({"method": "average", "fields": {}}, "must contain at least one field"), ({"method": "average", "fields": None}, "must contain at least one field"), ({"method": "average", "fields": {"field1": {"fields": {}}}}, "must contain at least one field"), ], ) def test_validation_errors_parametrized(self, invalid_config, expected_error): """Test custom validation logic in FieldConfig with parametrized inputs.""" with pytest.raises(ValidationError, match=expected_error): FieldConfig(**invalid_config) class TestEvalMetricsConfig: """Tests for EvalMetricsConfig model.""" def test_create_from_dict(self): """Test creating EvalMetricsConfig from dict.""" config_dict = { "report": { "method": "average", "fields": { "summary": {"method": "f1"}, "details": {"method": "exact_match"}, }, } } config = EvalMetricsConfig.from_dict(config_dict) assert config.root_key == "report" assert config.root.method == "average" assert len(config.root.fields) == 2 def test_from_dict_single_root_key(self): """Test from_dict requires exactly one root key.""" with pytest.raises(ValueError, match="exactly one root key"): EvalMetricsConfig.from_dict({"key1": {}, "key2": {}}) def test_from_dict_empty_dict(self): """Test from_dict with empty dict.""" with pytest.raises(ValueError, match="exactly one root key"): EvalMetricsConfig.from_dict({}) def test_from_dict_invalid_type(self): """Test from_dict with invalid type.""" with pytest.raises(ValueError, match="must be a dict"): EvalMetricsConfig.from_dict("not a dict") def test_methods_collected(self): """Test that methods are collected in config.""" config_dict = { "root": { "method": "average", "fields": { "field1": {"method": "f1"}, "field2": {"method": "exact_match"}, }, } } config = EvalMetricsConfig.from_dict(config_dict) assert len(config.methods) > 0 def test_config_with_dynamic_discovery(self): """Test config with dynamic field discovery.""" config_dict = { "root": { "method": "average", "allow_dynamic_field_discovery": True, } } config = EvalMetricsConfig.from_dict(config_dict) assert config.root.allow_dynamic_field_discovery is True def test_deep_nesting(self): """Test deeply nested configuration.""" config_dict = { "root": { "method": "average", "fields": { "level1": { "method": "average", "fields": { "level2": { "method": "f1", } }, } }, } } config = EvalMetricsConfig.from_dict(config_dict) assert config.root.fields["level1"].fields["level2"].method == "f1" ================================================ FILE: agent/tests/unit_test/evaluators/test_evaluate.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for report_evaluator/evaluate module.""" # Since evaluate module has complex dependencies, we test what we can import class TestEvaluateModuleImports: """Test that evaluate module can be imported.""" def test_module_import(self): # Test that the module can be imported without errors from vss_agents.evaluators.report_evaluator import evaluate assert evaluate is not None class TestEvaluationHelpers: """Test helper functionality from evaluate module.""" def test_evaluation_metrics_exist(self): """Test that evaluation metrics are defined.""" from vss_agents.evaluators.report_evaluator.field_evaluators.base import METRIC_REGISTRY from vss_agents.evaluators.report_evaluator.field_evaluators.base import register_metric assert callable(register_metric) assert isinstance(METRIC_REGISTRY, dict) ================================================ FILE: agent/tests/unit_test/evaluators/test_evaluate_patch.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for evaluators/evaluate_patch module.""" import json from pathlib import Path from unittest.mock import MagicMock from nat.eval.evaluator.evaluator_model import EvalInputItem import pytest from vss_agents.evaluators.evaluate_patch import DatasetFilter from vss_agents.evaluators.evaluate_patch import _expand_multi_turn_items from vss_agents.evaluators.evaluate_patch import _filter_by_dataset_filter from vss_agents.evaluators.evaluate_patch import _get_conversation from vss_agents.evaluators.evaluate_patch import _write_latency_summary from vss_agents.evaluators.evaluate_patch import is_multi_turn_item # --- Helpers --- def _make_item(item_id: str, query: str = "q", full_dataset_entry: dict | None = None) -> EvalInputItem: return EvalInputItem( id=item_id, input_obj=query, output_obj=None, expected_output_obj=None, full_dataset_entry=full_dataset_entry or {}, ) def _make_multi_turn_entry(turns: list[dict]) -> dict: return { "id": "mt_001", "query": "[multi-turn]", "conversation": turns, } # --- _get_conversation --- class TestGetConversation: def test_returns_list_when_present(self): entry = {"conversation": [{"turn_id": "turn_1", "query": "hello"}]} assert _get_conversation(entry) == [{"turn_id": "turn_1", "query": "hello"}] def test_returns_empty_for_missing_key(self): assert _get_conversation({}) == [] @pytest.mark.parametrize("value", [float("nan"), None, "not a list"]) def test_returns_empty_for_non_list(self, value): assert _get_conversation({"conversation": value}) == [] def test_returns_empty_list_as_is(self): assert _get_conversation({"conversation": []}) == [] # --- is_multi_turn_item --- class TestIsMultiTurnItem: def test_true_with_conversation(self): entry = {"conversation": [{"turn_id": "turn_1", "query": "hi"}]} assert is_multi_turn_item(entry) is True def test_false_without_conversation(self): assert is_multi_turn_item({"query": "single"}) is False def test_false_with_empty_conversation(self): assert is_multi_turn_item({"conversation": []}) is False # --- _expand_multi_turn_items --- class TestExpandMultiTurnItems: def test_single_turn_passes_through(self): item = _make_item("st_001", full_dataset_entry={"query": "hello"}) result = _expand_multi_turn_items([item]) assert len(result) == 1 assert result[0] is item def test_multi_turn_expanded(self): entry = _make_multi_turn_entry( [ {"turn_id": "turn_1", "query": "q1", "ground_truth": "a1"}, {"turn_id": "turn_2", "query": "q2", "ground_truth": "a2"}, {"turn_id": "turn_3", "query": "q3"}, ] ) item = _make_item("mt_001", full_dataset_entry=entry) result = _expand_multi_turn_items([item]) assert len(result) == 3 assert result[0].id == "mt_001_turn_1" assert result[0].input_obj == "q1" assert result[0].expected_output_obj == "a1" assert result[1].id == "mt_001_turn_2" assert result[2].id == "mt_001_turn_3" assert result[2].expected_output_obj is None def test_expanded_items_share_conversation_id(self): entry = _make_multi_turn_entry( [ {"turn_id": "turn_1", "query": "q1"}, {"turn_id": "turn_2", "query": "q2"}, ] ) item = _make_item("mt_001", full_dataset_entry=entry) result = _expand_multi_turn_items([item]) conv_id_1 = result[0].full_dataset_entry["_multi_turn_conversation_id"] conv_id_2 = result[1].full_dataset_entry["_multi_turn_conversation_id"] assert conv_id_1 == conv_id_2 assert conv_id_1.startswith("multi_turn_mt_001_") def test_default_turn_id(self): entry = _make_multi_turn_entry([{"query": "q1"}, {"query": "q2"}]) item = _make_item("mt_001", full_dataset_entry=entry) result = _expand_multi_turn_items([item]) assert result[0].id == "mt_001_turn_1" assert result[1].id == "mt_001_turn_2" def test_mixed_single_and_multi(self): single = _make_item("st_001", full_dataset_entry={"query": "hello"}) multi_entry = _make_multi_turn_entry( [ {"turn_id": "turn_1", "query": "q1"}, {"turn_id": "turn_2", "query": "q2"}, ] ) multi = _make_item("mt_001", full_dataset_entry=multi_entry) result = _expand_multi_turn_items([single, multi]) assert len(result) == 3 assert result[0].id == "st_001" assert result[1].id == "mt_001_turn_1" assert result[2].id == "mt_001_turn_2" def test_preserves_turn_fields(self): entry = _make_multi_turn_entry( [ {"turn_id": "turn_1", "query": "q1", "evaluation_method": ["qa"], "extra_field": "value"}, ] ) item = _make_item("mt_001", full_dataset_entry=entry) result = _expand_multi_turn_items([item]) assert result[0].full_dataset_entry["evaluation_method"] == ["qa"] assert result[0].full_dataset_entry["extra_field"] == "value" # --- _filter_by_dataset_filter --- class TestFilterByDatasetFilter: def test_empty_filter_returns_all(self): items = [_make_item("a", full_dataset_entry={"evaluation_method": ["qa"]})] assert _filter_by_dataset_filter(items, []) == items def test_single_turn_matching(self): item_qa = _make_item("qa_001", full_dataset_entry={"evaluation_method": ["qa"]}) item_traj = _make_item("traj_001", full_dataset_entry={"evaluation_method": ["trajectory"]}) result = _filter_by_dataset_filter([item_qa, item_traj], ["trajectory"]) assert len(result) == 1 assert result[0].id == "traj_001" def test_single_turn_no_match(self): item = _make_item("qa_001", full_dataset_entry={"evaluation_method": ["qa"]}) result = _filter_by_dataset_filter([item], ["trajectory"]) assert len(result) == 0 def test_narrows_evaluation_method(self): item = _make_item("item_001", full_dataset_entry={"evaluation_method": ["qa", "trajectory"]}) _filter_by_dataset_filter([item], ["trajectory"]) assert item.full_dataset_entry["evaluation_method"] == ["trajectory"] def test_narrows_multi_method_to_multiple(self): item = _make_item("item_001", full_dataset_entry={"evaluation_method": ["qa", "trajectory", "report"]}) _filter_by_dataset_filter([item], ["qa", "trajectory"]) assert item.full_dataset_entry["evaluation_method"] == ["qa", "trajectory"] def test_multi_turn_keeps_whole_conversation_if_any_turn_matches(self): conv_id = "conv_001" turn1 = _make_item( "t1", full_dataset_entry={ "_multi_turn_conversation_id": conv_id, "evaluation_method": ["qa"], }, ) turn2 = _make_item( "t2", full_dataset_entry={ "_multi_turn_conversation_id": conv_id, "evaluation_method": ["trajectory"], }, ) result = _filter_by_dataset_filter([turn1, turn2], ["trajectory"]) assert len(result) == 2 def test_multi_turn_narrows_evaluation_methods(self): conv_id = "conv_001" turn1 = _make_item( "t1", full_dataset_entry={ "_multi_turn_conversation_id": conv_id, "evaluation_method": ["qa", "trajectory"], }, ) turn2 = _make_item( "t2", full_dataset_entry={ "_multi_turn_conversation_id": conv_id, "evaluation_method": ["qa"], }, ) _filter_by_dataset_filter([turn1, turn2], ["trajectory"]) assert turn1.full_dataset_entry["evaluation_method"] == ["trajectory"] assert turn2.full_dataset_entry["evaluation_method"] == [] def test_multi_turn_filters_out_entire_conversation_if_no_turn_matches(self): conv_id = "conv_001" turn1 = _make_item( "t1", full_dataset_entry={ "_multi_turn_conversation_id": conv_id, "evaluation_method": ["qa"], }, ) turn2 = _make_item( "t2", full_dataset_entry={ "_multi_turn_conversation_id": conv_id, "evaluation_method": ["qa"], }, ) result = _filter_by_dataset_filter([turn1, turn2], ["trajectory"]) assert len(result) == 0 def test_mixed_single_and_multi_turn(self): single_qa = _make_item("sq", full_dataset_entry={"evaluation_method": ["qa"]}) single_traj = _make_item("st", full_dataset_entry={"evaluation_method": ["trajectory"]}) conv_id = "conv_001" mt_turn1 = _make_item( "mt1", full_dataset_entry={ "_multi_turn_conversation_id": conv_id, "evaluation_method": ["trajectory"], }, ) mt_turn2 = _make_item( "mt2", full_dataset_entry={ "_multi_turn_conversation_id": conv_id, "evaluation_method": ["qa"], }, ) result = _filter_by_dataset_filter([single_qa, single_traj, mt_turn1, mt_turn2], ["trajectory"]) ids = [item.id for item in result] assert "sq" not in ids assert "st" in ids assert "mt1" in ids assert "mt2" in ids def test_non_list_evaluation_method_skipped(self): item = _make_item("bad", full_dataset_entry={"evaluation_method": "qa"}) result = _filter_by_dataset_filter([item], ["qa"]) assert len(result) == 0 def test_missing_evaluation_method_skipped(self): item = _make_item("no_method", full_dataset_entry={}) result = _filter_by_dataset_filter([item], ["qa"]) assert len(result) == 0 # --- _write_latency_summary --- class TestWriteLatencySummary: def test_writes_json_file(self, tmp_path): mock_run = MagicMock() mock_run.eval_config.general.output_dir = tmp_path item1 = _make_item("item_1", query="q1") item1.trajectory = [MagicMock(event_timestamp=10.0), MagicMock(event_timestamp=15.0)] item2 = _make_item("item_2", query="q2") item2.trajectory = [MagicMock(event_timestamp=20.0), MagicMock(event_timestamp=22.0)] avg = _write_latency_summary(mock_run, [item1, item2]) summary_file = tmp_path / "latency_summary.json" assert summary_file.exists() data = json.loads(summary_file.read_text()) assert data["average_latency_seconds"] == pytest.approx(3.5, abs=0.01) assert len(data["items"]) == 2 assert data["items"][0]["id"] == "item_1" assert data["items"][0]["latency_seconds"] == pytest.approx(5.0) assert data["items"][1]["latency_seconds"] == pytest.approx(2.0) assert avg == pytest.approx(3.5, abs=0.01) def test_returns_none_for_no_trajectory(self, tmp_path): mock_run = MagicMock() mock_run.eval_config.general.output_dir = tmp_path item = _make_item("item_1", query="q1") item.trajectory = [] avg = _write_latency_summary(mock_run, [item]) data = json.loads((tmp_path / "latency_summary.json").read_text()) assert data["average_latency_seconds"] is None assert data["items"][0]["latency_seconds"] is None assert avg is None def test_returns_none_on_error(self): mock_run = MagicMock() mock_run.eval_config.general.output_dir = Path("/nonexistent/deeply/nested/path") result = _write_latency_summary(mock_run, []) assert result is None # --- DATASET_FILTER env var validation (tested via the patch internals) --- class TestDatasetFilterValidation: """Test the validation logic that runs inside patched_run_workflow_local. We extract the validation logic and test it directly since the actual patch requires a full EvaluationRun setup. """ @staticmethod def _validate_dataset_filter(env_value: str) -> list[str]: """Reproduce the validation logic from patched_run_workflow_local.""" valid_filters = {f.value for f in DatasetFilter} dataset_filter_env = env_value.strip().lower() dataset_filter = [s.strip() for s in dataset_filter_env.split(",") if s.strip()] invalid = set(dataset_filter) - valid_filters if invalid: raise ValueError( f"Invalid DATASET_FILTER values: {invalid}. Must be one of: {[f.value for f in DatasetFilter]}" ) if DatasetFilter.ALL.value in dataset_filter and len(dataset_filter) > 1: raise ValueError("DATASET_FILTER='all' cannot be combined with other values") return dataset_filter def test_all_is_valid(self): assert self._validate_dataset_filter("all") == ["all"] def test_single_filter(self): assert self._validate_dataset_filter("qa") == ["qa"] assert self._validate_dataset_filter("trajectory") == ["trajectory"] assert self._validate_dataset_filter("report") == ["report"] def test_multiple_filters(self): result = self._validate_dataset_filter("qa,trajectory") assert set(result) == {"qa", "trajectory"} def test_whitespace_handling(self): result = self._validate_dataset_filter(" qa , trajectory ") assert set(result) == {"qa", "trajectory"} def test_case_insensitive(self): assert self._validate_dataset_filter("QA") == ["qa"] assert self._validate_dataset_filter("Trajectory") == ["trajectory"] def test_invalid_value_raises(self): with pytest.raises(ValueError, match="Invalid DATASET_FILTER"): self._validate_dataset_filter("invalid") def test_all_combined_with_others_raises(self): with pytest.raises(ValueError, match="cannot be combined"): self._validate_dataset_filter("all,qa") ================================================ FILE: agent/tests/unit_test/evaluators/test_field_evaluators.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/evaluators/report_evaluator/field_evaluators/.""" import pytest from vss_agents.evaluators.report_evaluator.field_evaluators.base import METRIC_REGISTRY from vss_agents.evaluators.report_evaluator.field_evaluators.base import EvaluationMetric from vss_agents.evaluators.report_evaluator.field_evaluators.base import register_metric from vss_agents.evaluators.report_evaluator.field_evaluators.common import ExactMatchMetric from vss_agents.evaluators.report_evaluator.field_evaluators.common import F1Metric from vss_agents.evaluators.report_evaluator.field_evaluators.common import NonEmptyMetric from vss_agents.evaluators.report_evaluator.field_evaluators.common import RegexMetric from vss_agents.evaluators.report_evaluator.field_evaluators.common import calculate_f1_score from vss_agents.evaluators.report_evaluator.field_evaluators.common import tokenize_text class TestTokenizeText: """Tests for tokenize_text function.""" def test_tokenize_simple_text(self): """Test tokenizing simple text.""" result = tokenize_text("Hello World") assert result == ["hello", "world"] def test_tokenize_with_punctuation(self): """Test tokenizing text with punctuation.""" result = tokenize_text("Hello, World! How are you?") assert "hello" in result assert "world" in result assert "how" in result def test_tokenize_numbers(self): """Test tokenizing text with numbers.""" result = tokenize_text("Test123 456") assert "test123" in result assert "456" in result def test_tokenize_empty_string(self): """Test tokenizing empty string.""" result = tokenize_text("") assert result == [] def test_tokenize_case_insensitive(self): """Test that tokenization is case insensitive.""" result = tokenize_text("HELLO hello HeLLo") assert result == ["hello", "hello", "hello"] class TestCalculateF1Score: """Tests for calculate_f1_score function.""" def test_f1_identical_tokens(self): """Test F1 score with identical tokens.""" score = calculate_f1_score(["a", "b", "c"], ["a", "b", "c"]) assert score == 1.0 def test_f1_no_overlap(self): """Test F1 score with no overlap.""" score = calculate_f1_score(["a", "b"], ["c", "d"]) assert score == 0.0 def test_f1_partial_overlap(self): """Test F1 score with partial overlap.""" score = calculate_f1_score(["a", "b", "c"], ["a", "b", "d"]) assert 0 < score < 1 def test_f1_both_empty(self): """Test F1 score with both empty lists.""" score = calculate_f1_score([], []) assert score == 1.0 def test_f1_pred_empty(self): """Test F1 score with empty prediction.""" score = calculate_f1_score([], ["a", "b"]) assert score == 0.0 def test_f1_ref_empty(self): """Test F1 score with empty reference.""" score = calculate_f1_score(["a", "b"], []) assert score == 0.0 def test_f1_single_token_match(self): """Test F1 score with single matching token.""" score = calculate_f1_score(["hello"], ["hello"]) assert score == 1.0 def test_f1_zero_precision_recall_edge_case(self): """Test F1 score edge case where precision + recall could be 0.""" # This tests line 50 - though it's hard to reach since # intersection check comes first score = calculate_f1_score(["x"], ["y"]) assert score == 0.0 class TestNonEmptyMetric: """Tests for NonEmptyMetric.""" @pytest.mark.asyncio async def test_non_empty_with_content(self): """Test non-empty metric with content.""" metric = NonEmptyMetric() score = await metric.evaluate("some content", "reference") assert score == 1.0 @pytest.mark.asyncio async def test_non_empty_with_empty_string(self): """Test non-empty metric with empty string.""" metric = NonEmptyMetric() score = await metric.evaluate("", "reference") assert score == 0.0 @pytest.mark.asyncio async def test_non_empty_with_whitespace_only(self): """Test non-empty metric with whitespace only.""" metric = NonEmptyMetric() score = await metric.evaluate(" ", "reference") assert score == 0.0 @pytest.mark.asyncio async def test_non_empty_with_none(self): """Test non-empty metric with None.""" metric = NonEmptyMetric() score = await metric.evaluate(None, "reference") assert score == 0.0 class TestF1Metric: """Tests for F1Metric.""" @pytest.mark.asyncio async def test_f1_metric_identical(self): """Test F1 metric with identical strings.""" metric = F1Metric() score = await metric.evaluate("hello world", "hello world") assert score == 1.0 @pytest.mark.asyncio async def test_f1_metric_different(self): """Test F1 metric with different strings.""" metric = F1Metric() score = await metric.evaluate("hello world", "goodbye moon") assert score == 0.0 @pytest.mark.asyncio async def test_f1_metric_partial(self): """Test F1 metric with partial match.""" metric = F1Metric() score = await metric.evaluate("hello world test", "hello world other") assert 0 < score < 1 class TestExactMatchMetric: """Tests for ExactMatchMetric.""" @pytest.mark.asyncio async def test_exact_match_identical(self): """Test exact match with identical strings.""" metric = ExactMatchMetric() score = await metric.evaluate("hello world", "hello world") assert score == 1.0 @pytest.mark.asyncio async def test_exact_match_different(self): """Test exact match with different strings.""" metric = ExactMatchMetric() score = await metric.evaluate("hello", "world") assert score == 0.0 @pytest.mark.asyncio async def test_exact_match_whitespace_normalized(self): """Test exact match with normalized whitespace.""" metric = ExactMatchMetric() score = await metric.evaluate("hello world", "hello world") assert score == 1.0 @pytest.mark.asyncio async def test_exact_match_case_sensitive(self): """Test exact match is case sensitive.""" metric = ExactMatchMetric() score = await metric.evaluate("Hello", "hello") assert score == 0.0 class TestRegexMetric: """Tests for RegexMetric.""" @pytest.mark.asyncio async def test_regex_match(self): """Test regex match.""" metric = RegexMetric() score = await metric.evaluate("hello123", r"hello\d+") assert score == 1.0 @pytest.mark.asyncio async def test_regex_no_match(self): """Test regex no match.""" metric = RegexMetric() score = await metric.evaluate("hello", r"world") assert score == 0.0 @pytest.mark.asyncio async def test_regex_invalid_pattern(self): """Test regex with invalid pattern.""" metric = RegexMetric() score = await metric.evaluate("test", r"[invalid(pattern") assert score == 0.0 @pytest.mark.asyncio async def test_regex_email_pattern(self): """Test regex with email pattern.""" metric = RegexMetric() score = await metric.evaluate("test@example.com", r"[\w.]+@[\w.]+\.\w+") assert score == 1.0 class TestRegisterMetric: """Tests for register_metric decorator.""" def test_register_new_metric(self): """Test registering a new metric.""" # Note: We can't easily test this without modifying the registry # Just verify the registry contains expected metrics assert "f1" in METRIC_REGISTRY assert "exact_match" in METRIC_REGISTRY assert "non_empty" in METRIC_REGISTRY assert "regex" in METRIC_REGISTRY def test_duplicate_registration_raises(self): """Test that duplicate registration raises error.""" with pytest.raises(ValueError, match="already registered"): @register_metric("f1") # f1 is already registered class DuplicateMetric(EvaluationMetric): async def evaluate(self, actual, reference, field_name=""): return 1.0 ================================================ FILE: agent/tests/unit_test/evaluators/test_llm_judge.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for llm_judge module.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from pydantic import ValidationError import pytest from vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge import FieldEvaluation from vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge import LLMJudgeMetric class TestFieldEvaluation: """Test FieldEvaluation model.""" def test_field_evaluation_basic(self): eval_result = FieldEvaluation(score=0.85, reference_field="title") assert eval_result.score == 0.85 assert eval_result.reference_field == "title" def test_field_evaluation_no_match(self): eval_result = FieldEvaluation(score=0.0, reference_field=None) assert eval_result.score == 0.0 assert eval_result.reference_field is None def test_field_evaluation_perfect_score(self): eval_result = FieldEvaluation(score=1.0, reference_field="name") assert eval_result.score == 1.0 def test_field_evaluation_score_bounds(self): # Score must be between 0 and 1 eval_result = FieldEvaluation(score=0.0) assert eval_result.score == 0.0 eval_result = FieldEvaluation(score=1.0) assert eval_result.score == 1.0 def test_field_evaluation_invalid_score_above(self): with pytest.raises(ValidationError): FieldEvaluation(score=1.5) def test_field_evaluation_invalid_score_below(self): with pytest.raises(ValidationError): FieldEvaluation(score=-0.1) class TestLLMJudgeMetric: """Test LLMJudgeMetric class.""" def test_init_missing_llm(self): with pytest.raises(ValueError, match="requires 'llm_name'"): LLMJudgeMetric(single_field_comparison_prompt="test") def test_init_missing_prompt(self): mock_llm = MagicMock() with pytest.raises(ValueError, match="requires 'single_field_comparison_prompt'"): LLMJudgeMetric(llm=mock_llm) def test_init_success(self): mock_llm = MagicMock() metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare: {reference} vs {actual}", ) assert metric.llm is mock_llm assert metric.max_retries == 2 def test_init_custom_max_retries(self): mock_llm = MagicMock() metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="test", max_retries=5, ) assert metric.max_retries == 5 def test_init_with_multi_field_prompt(self): mock_llm = MagicMock() metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="single", multi_field_discovery_prompt="multi", ) assert metric.multi_field_discovery_prompt == "multi" @pytest.mark.asyncio async def test_evaluate_with_strings(self): # Create a mock response object with .content attribute mock_response = MagicMock() mock_response.content = "0.85" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} mock_llm = AsyncMock() mock_llm.model_name = "test-model" mock_llm.ainvoke = AsyncMock(return_value=mock_response) metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="{field_context}\nReference: {reference}\nActual: {actual}", ) result = await metric.evaluate("actual value", "reference value", "test_field") assert result == 0.85 mock_llm.ainvoke.assert_called_once() @pytest.mark.asyncio async def test_evaluate_with_dicts(self): # Create a mock response object with .content attribute mock_response = MagicMock() mock_response.content = "0.9" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} mock_llm = AsyncMock() mock_llm.model_name = "test-model" mock_llm.ainvoke = AsyncMock(return_value=mock_response) metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="{field_context}\nReference: {reference}\nActual: {actual}", ) result = await metric.evaluate({"key": "value1"}, {"key": "value2"}, "dict_field") assert result == 0.9 @pytest.mark.asyncio async def test_evaluate_llm_error_returns_none(self): mock_llm = AsyncMock() mock_llm.model_name = "test-model" mock_llm.ainvoke = AsyncMock(side_effect=Exception("LLM error")) metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="{field_context}\nReference: {reference}\nActual: {actual}", max_retries=0, ) result = await metric.evaluate("actual", "reference") assert result is None @pytest.mark.asyncio async def test_evaluate_with_field_discovery_no_prompt(self): mock_llm = MagicMock() metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="test", ) with pytest.raises(ValueError, match="multi_field_discovery_prompt"): await metric.evaluate_with_field_discovery( {"ref": "value"}, {"actual": "value"}, ["field1"], ) @pytest.mark.asyncio async def test_evaluate_with_field_discovery_empty_fields(self): mock_llm = MagicMock() metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="test", multi_field_discovery_prompt="multi", ) result = await metric.evaluate_with_field_discovery({}, {}, []) assert result == {} ================================================ FILE: agent/tests/unit_test/evaluators/test_llm_judge_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for llm_judge module to improve coverage.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge import FieldEvaluation from vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge import LLMJudgeMetric class TestFieldEvaluation: """Test FieldEvaluation model.""" def test_basic(self): fe = FieldEvaluation(score=0.95, reference_field="location") assert fe.score == 0.95 assert fe.reference_field == "location" def test_no_match(self): fe = FieldEvaluation(score=0.0, reference_field=None) assert fe.score == 0.0 assert fe.reference_field is None def test_score_bounds(self): fe = FieldEvaluation(score=0.0) assert fe.score == 0.0 fe = FieldEvaluation(score=1.0) assert fe.score == 1.0 class TestLLMJudgeMetricInit: """Test LLMJudgeMetric initialization.""" def test_missing_llm_raises(self): with pytest.raises(ValueError, match="requires 'llm_name'"): LLMJudgeMetric(single_field_comparison_prompt="test") def test_missing_prompt_raises(self): mock_llm = MagicMock() mock_llm.model_name = "test" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): with pytest.raises(ValueError, match="single_field_comparison_prompt"): LLMJudgeMetric(llm=mock_llm) def test_valid_init(self): mock_llm = MagicMock() mock_llm.model_name = "test-model" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} reference: {reference} actual: {actual}", ) assert metric.llm is mock_llm assert metric.max_retries == 2 assert metric.llm_judge_reasoning is True def test_with_thinking_tag(self): mock_llm = MagicMock() mock_llm.model_name = "test-model" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value="", ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={"thinking": True}, ): metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} ref: {reference} act: {actual}", ) assert metric.thinking_tag == "" def test_with_multi_field_prompt(self): mock_llm = MagicMock() mock_llm.model_name = "test-model" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="prompt {field_context} {reference} {actual}", multi_field_discovery_prompt="multi prompt {reference_section} {actual_fields}", ) assert metric.multi_field_discovery_prompt is not None class TestLLMJudgeMetricEvaluate: """Test LLMJudgeMetric.evaluate method.""" @pytest.fixture def mock_metric(self): mock_llm = MagicMock() mock_llm.model_name = "test-model" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): return LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} reference: {reference} actual: {actual}", ) @pytest.mark.asyncio async def test_evaluate_success(self, mock_metric): mock_response = MagicMock() mock_response.content = "0.85" mock_response.additional_kwargs = {} mock_metric.llm.ainvoke = AsyncMock(return_value=mock_response) with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.parse_reasoning_content", return_value=(None, "0.85"), ): result = await mock_metric.evaluate("actual value", "reference value", "test_field") assert result == 0.85 @pytest.mark.asyncio async def test_evaluate_with_dict_values(self, mock_metric): mock_response = MagicMock() mock_response.content = "0.9" mock_response.additional_kwargs = {} mock_metric.llm.ainvoke = AsyncMock(return_value=mock_response) with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.parse_reasoning_content", return_value=(None, "0.9"), ): result = await mock_metric.evaluate({"key": "actual"}, {"key": "reference"}, "test_field") assert result == 0.9 @pytest.mark.asyncio async def test_evaluate_failure_returns_none(self, mock_metric): mock_metric.llm.ainvoke = AsyncMock(side_effect=RuntimeError("LLM error")) result = await mock_metric.evaluate("actual", "reference", "test_field") assert result is None class TestLLMJudgeMetricInvokeLLM: """Test LLMJudgeMetric._invoke_llm method.""" @pytest.fixture def mock_metric(self): mock_llm = MagicMock() mock_llm.model_name = "test-model" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): return LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} {reference} {actual}", max_retries=2, ) @pytest.mark.asyncio async def test_invoke_with_retries(self, mock_metric): mock_response = MagicMock() mock_response.content = "0.5" mock_response.additional_kwargs = {} mock_metric.llm.ainvoke = AsyncMock(side_effect=[ValueError("parse error"), mock_response]) with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.parse_reasoning_content", return_value=(None, "0.5"), ): result = await mock_metric._invoke_llm( prompt="test prompt", parser=lambda x: float(x.strip()), context="test", ) assert result == 0.5 @pytest.mark.asyncio async def test_invoke_all_retries_fail(self, mock_metric): mock_metric.llm.ainvoke = AsyncMock(side_effect=ValueError("always fails")) with pytest.raises(ValueError, match="LLM failed after"): await mock_metric._invoke_llm( prompt="test", parser=lambda x: float(x), context="test", ) class TestLLMJudgeMetricFieldDiscovery: """Test LLMJudgeMetric.evaluate_with_field_discovery method.""" @pytest.fixture def mock_metric(self): mock_llm = MagicMock() mock_llm.model_name = "test-model" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): return LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} {reference} {actual}", multi_field_discovery_prompt="Score: {reference_section} vs {actual_fields}", ) @pytest.mark.asyncio async def test_empty_unspecified_fields(self, mock_metric): result = await mock_metric.evaluate_with_field_discovery( reference_section={}, actual_section={}, unspecified_fields=[] ) assert result == {} @pytest.mark.asyncio async def test_missing_multi_field_prompt_raises(self): mock_llm = MagicMock() mock_llm.model_name = "test-model" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} {reference} {actual}", ) with pytest.raises(ValueError, match="multi_field_discovery_prompt"): await metric.evaluate_with_field_discovery( reference_section={"a": "b"}, actual_section={"a": "c"}, unspecified_fields=["a"], ) @pytest.mark.asyncio async def test_field_discovery_exception_returns_none(self, mock_metric): """Test that exceptions in field discovery return None for all fields.""" mock_structured_llm = AsyncMock() mock_structured_llm.ainvoke.side_effect = RuntimeError("LLM error") mock_metric.llm.with_structured_output = MagicMock(return_value=mock_structured_llm) result = await mock_metric.evaluate_with_field_discovery( reference_section={"field1": "ref"}, actual_section={"field1": "act"}, unspecified_fields=["field1"], ) assert result == {"field1": None} ================================================ FILE: agent/tests/unit_test/evaluators/test_llm_judge_field_discovery.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for LLM judge field discovery to cover remaining lines.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge import LLMJudgeMetric class TestLLMJudgeFieldDiscoverySuccess: """Test successful field discovery.""" @pytest.mark.asyncio async def test_field_discovery_success(self): mock_llm = MagicMock() mock_llm.model_name = "test" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} {reference} {actual}", multi_field_discovery_prompt="Score: {reference_section} vs {actual_fields}", ) # Create mock structured output mock_result = MagicMock() mock_field_eval = MagicMock() mock_field_eval.score = 0.85 mock_field_eval.reference_field = "location" mock_result.field1 = mock_field_eval mock_structured_llm = AsyncMock() mock_structured_llm.ainvoke.return_value = mock_result metric.llm.with_structured_output = MagicMock(return_value=mock_structured_llm) result = await metric.evaluate_with_field_discovery( reference_section={"location": "San Jose"}, actual_section={"field1": "San Jose, CA"}, unspecified_fields=["field1"], ) assert "field1" in result assert result["field1"]["score"] == 0.85 assert result["field1"]["reference_field"] == "location" @pytest.mark.asyncio async def test_field_discovery_missing_attribute(self): """Test when structured output missing a field attribute.""" mock_llm = MagicMock() mock_llm.model_name = "test" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} {reference} {actual}", multi_field_discovery_prompt="Score: {reference_section} vs {actual_fields}", ) mock_result = MagicMock(spec=[]) # Empty spec so getattr raises AttributeError del mock_result.missing_field # Ensure attribute doesn't exist mock_structured_llm = AsyncMock() mock_structured_llm.ainvoke.return_value = mock_result metric.llm.with_structured_output = MagicMock(return_value=mock_structured_llm) result = await metric.evaluate_with_field_discovery( reference_section={"location": "SJ"}, actual_section={"missing_field": "value"}, unspecified_fields=["missing_field"], ) assert result["missing_field"] is None @pytest.mark.asyncio async def test_evaluate_with_non_str_actual(self): """Test evaluate with non-string, non-dict actual value.""" mock_llm = MagicMock() mock_llm.model_name = "test" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value=None ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} reference: {reference} actual: {actual}", ) mock_response = MagicMock() mock_response.content = "0.75" mock_response.additional_kwargs = {} metric.llm.ainvoke = AsyncMock(return_value=mock_response) with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.parse_reasoning_content", return_value=(None, "0.75"), ): result = await metric.evaluate(42, 42, "number_field") assert result == 0.75 @pytest.mark.asyncio async def test_invoke_with_thinking_tag(self): """Test _invoke_llm with thinking tag set.""" mock_llm = MagicMock() mock_llm.model_name = "test" with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_thinking_tag", return_value="", ): with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.get_llm_reasoning_bind_kwargs", return_value={}, ): metric = LLMJudgeMetric( llm=mock_llm, single_field_comparison_prompt="Compare {field_context} {reference} {actual}", ) mock_response = MagicMock() mock_response.content = "0.9" mock_response.additional_kwargs = {} metric.llm.ainvoke = AsyncMock(return_value=mock_response) with patch( "vss_agents.evaluators.report_evaluator.field_evaluators.llm_judge.parse_reasoning_content", return_value=(None, "0.9"), ): result = await metric._invoke_llm("test prompt", lambda x: float(x.strip())) assert result == 0.9 ================================================ FILE: agent/tests/unit_test/evaluators/test_register_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for evaluator register modules to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.evaluators.customized_qa_evaluator.register import CustomizedQAEvaluatorConfig from vss_agents.evaluators.customized_trajectory_evaluator.register import CustomizedTrajectoryEvaluatorConfig class TestCustomizedQAEvaluatorConfig: """Test CustomizedQAEvaluatorConfig model.""" def test_required_fields(self): config = CustomizedQAEvaluatorConfig(llm_name="gpt-4o") assert config.llm_name == "gpt-4o" assert config.evaluation_method_id == "qa" assert config.custom_prompt_template is None assert config.max_retries == 2 assert config.llm_judge_reasoning is True def test_custom_values(self): config = CustomizedQAEvaluatorConfig( llm_name="custom-llm", evaluation_method_id="custom_qa", custom_prompt_template="Custom template {question} {answer} {reference}", max_retries=5, llm_judge_reasoning=False, ) assert config.evaluation_method_id == "custom_qa" assert config.custom_prompt_template is not None assert config.max_retries == 5 assert config.llm_judge_reasoning is False def test_missing_llm_name_raises(self): with pytest.raises(ValidationError): CustomizedQAEvaluatorConfig() class TestCustomizedTrajectoryEvaluatorConfig: """Test CustomizedTrajectoryEvaluatorConfig model.""" def test_required_fields(self): config = CustomizedTrajectoryEvaluatorConfig(llm_name="gpt-4o") assert config.llm_name == "gpt-4o" assert config.evaluation_method_id == "trajectory" assert config.track_agent_selected_tools_only is True assert config.custom_prompt_template_with_reference is None assert config.custom_prompt_template_without_reference is None assert config.max_retries == 2 assert config.llm_judge_reasoning is True def test_custom_values(self): config = CustomizedTrajectoryEvaluatorConfig( llm_name="custom-llm", evaluation_method_id="custom_traj", track_agent_selected_tools_only=False, custom_prompt_template_with_reference="Template {question} {agent_trajectory} {answer} {reference}", custom_prompt_template_without_reference="Template {question} {agent_trajectory} {answer} {tool_schemas} {conversation_history}", max_retries=3, llm_judge_reasoning=False, ) assert config.track_agent_selected_tools_only is False assert config.custom_prompt_template_with_reference is not None assert config.custom_prompt_template_without_reference is not None assert config.max_retries == 3 def test_missing_llm_name_raises(self): with pytest.raises(ValidationError): CustomizedTrajectoryEvaluatorConfig() ================================================ FILE: agent/tests/unit_test/evaluators/test_report_evaluator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json from pathlib import Path import tempfile from typing import Any from unittest.mock import AsyncMock from unittest.mock import Mock from unittest.mock import patch from nat.eval.evaluator.evaluator_model import EvalInputItem import pytest import yaml from vss_agents.evaluators.report_evaluator.data_models import EvaluationScore from vss_agents.evaluators.report_evaluator.eval_config_models import EvalMetricsConfig from vss_agents.evaluators.report_evaluator.eval_config_models import FieldConfig from vss_agents.evaluators.report_evaluator.evaluate import ReportEvaluator from vss_agents.evaluators.report_evaluator.evaluate import _fetch_and_parse_report from vss_agents.evaluators.report_evaluator.evaluate import _load_eval_metrics_yaml from vss_agents.evaluators.report_evaluator.field_evaluators.base import EvaluationMetric MOCK_METRIC_SCORE = 0.8 MOCK_LLM_JUDGE_SCORE = 0.9 class MockMetric(EvaluationMetric): """Mock evaluation metric for testing.""" def __init__(self, score: float = 1.0): self.score = score async def evaluate(self, actual: Any, reference: Any, field_name: str = "") -> float | None: """Return mock score.""" return self.score class MockLLMJudge(EvaluationMetric): """Mock LLM judge metric with field discovery capability.""" def __init__(self, score: float = 1.0): self.score = score async def evaluate(self, actual: Any, reference: Any, field_name: str = "") -> float | None: """Return mock score.""" return self.score async def evaluate_with_field_discovery( self, reference_section: dict, actual_section: dict, unspecified_fields: list, ) -> dict: """Mock field discovery evaluation.""" results = {} for field in unspecified_fields: results[field] = {"score": self.score, "reference_field": None} return results class TestLoadEvalMetricsYAML: """Test cases for _load_eval_metrics_yaml function.""" def test_load_eval_metrics_yaml_success(self): """Test successful loading of eval metrics YAML.""" yaml_content = { "report": { "method": "average", "fields": { "summary": {"method": "llm_judge"}, "details": {"method": "exact_match"}, }, } } with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: yaml.dump(yaml_content, f) temp_path = f.name try: config = _load_eval_metrics_yaml(temp_path) assert isinstance(config, EvalMetricsConfig) assert config.root_key == "report" finally: Path(temp_path).unlink() @pytest.mark.parametrize( "yaml_content,expected_error", [ (None, "is empty"), # Empty file ( {"root1": {"method": "average"}, "root2": {"method": "average"}}, "Invalid evaluation metrics config", ), # Invalid config ], ) def test_load_eval_metrics_yaml_errors(self, yaml_content, expected_error): """Test error cases.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: if yaml_content: yaml.dump(yaml_content, f) temp_path = f.name try: with pytest.raises(ValueError) as exc_info: _load_eval_metrics_yaml(temp_path) assert expected_error in str(exc_info.value) finally: Path(temp_path).unlink() class TestFetchAndParseReport: """Test cases for _fetch_and_parse_report function.""" @pytest.mark.asyncio async def test_fetch_and_parse_report_success(self): """Test successful report fetching and parsing.""" markdown_content = "# Report\n\n## Summary\nTest summary" mock_obj = Mock() mock_obj.data = markdown_content.encode("utf-8") mock_client = AsyncMock() mock_client.get_object = AsyncMock(return_value=mock_obj) response = "Here is the report: report_123.md" url_pattern = r"report_(\w+\.md)" parsed, url = await _fetch_and_parse_report(mock_client, response, url_pattern) assert url == "report_123.md" assert isinstance(parsed, dict) mock_client.get_object.assert_called_once_with("123.md") @pytest.mark.asyncio @pytest.mark.parametrize( "response,url_pattern,mock_return,expected_error", [ ("No report here", r"report_(\w+)\.md", None, "No report URL found"), ("report_123.md", r"report_(\w+)\.md", None, "not found in object store"), ], ) async def test_fetch_and_parse_report_errors(self, response, url_pattern, mock_return, expected_error): """Test error cases.""" mock_client = AsyncMock() mock_client.get_object = AsyncMock(return_value=mock_return) with pytest.raises(ValueError) as exc_info: await _fetch_and_parse_report(mock_client, response, url_pattern) assert expected_error in str(exc_info.value) class TestReportEvaluator: def setup_method(self): """Set up test fixtures.""" self.config = EvalMetricsConfig.from_dict( { "report": { "method": "average", "fields": { "summary": {"method": "mock_metric"}, "details": {"method": "mock_metric"}, }, } } ) self.mock_metric = MockMetric(score=MOCK_METRIC_SCORE) self.mock_llm_judge = MockLLMJudge(score=MOCK_LLM_JUDGE_SCORE) self.metric_instances = {"mock_metric": self.mock_metric, "llm_judge": self.mock_llm_judge} self.mock_object_store = AsyncMock() self.report_url_pattern = r"report_(\w+\.md)" self.evaluator = ReportEvaluator( config=self.config, metric_instances=self.metric_instances, object_store_client=self.mock_object_store, report_url_pattern=self.report_url_pattern, include_vlm_output=True, vlm_related_fields=["vlm_field_1", "vlm_field_2", "vlm_field_3"], ) @pytest.mark.asyncio async def test_evaluate_tree_section_with_fields_verifies_averaging(self): """Test evaluate_tree correctly averages field scores.""" mock_metric_field1 = MockMetric(score=0.6) mock_metric_field2 = MockMetric(score=1.0) evaluator = ReportEvaluator( config=self.config, metric_instances={ "metric_field1": mock_metric_field1, "metric_field2": mock_metric_field2, "llm_judge": self.mock_llm_judge, }, object_store_client=self.mock_object_store, report_url_pattern=self.report_url_pattern, include_vlm_output=False, ) section_config = FieldConfig( method="average", fields={ "field1": FieldConfig(method="metric_field1"), "field2": FieldConfig(method="metric_field2"), }, ) reference = {"field1": "ref1", "field2": "ref2"} actual = {"field1": "act1", "field2": "act2"} result = await evaluator.evaluate_tree( reference=reference, actual=actual, config=section_config, path=["section"] ) assert isinstance(result, EvaluationScore) assert result.method == "average" assert len(result.field_scores) == 2 assert result.field_scores["field1"].section_score == 0.6 assert result.field_scores["field2"].section_score == 1.0 assert result.section_score == 0.8 @pytest.mark.asyncio async def test_evaluate_tree_default_to_llm_judge_when_method_none(self): """Test evaluate_tree defaults to llm_judge when method is None.""" field_config = FieldConfig() # method is None result = await self.evaluator.evaluate_tree(reference="ref", actual="act", config=field_config, path=["field"]) assert isinstance(result, EvaluationScore) assert result.method == "llm_judge" assert result.section_score == MOCK_LLM_JUDGE_SCORE @pytest.mark.asyncio @pytest.mark.parametrize( "setup_func,expected_error_msg", [ ("non_dict_reference", "Reference at 'section' is str, expected dict for section"), ("metric_returns_none", "Evaluation failed: metric returned None"), ("metric_raises_exception", "Metric evaluation failed"), ], ) async def test_evaluate_tree_error_scenarios(self, setup_func, expected_error_msg): """Test evaluate_tree handles various error scenarios.""" if setup_func == "non_dict_reference": section_config = FieldConfig(method="average", fields={"field1": FieldConfig(method="mock_metric")}) result = await self.evaluator.evaluate_tree( reference="not a dict", actual={"field1": "act1"}, config=section_config, path=["section"], ) elif setup_func == "metric_returns_none": none_metric = MockMetric(score=None) self.evaluator.metric_instances = {"none_metric": none_metric} field_config = FieldConfig(method="none_metric") result = await self.evaluator.evaluate_tree( reference="ref", actual="act", config=field_config, path=["field"] ) else: # metric_raises_exception failing_metric = MockMetric(score=MOCK_METRIC_SCORE) async def failing_evaluate(actual, reference, field_name): raise RuntimeError("Metric evaluation failed") failing_metric.evaluate = failing_evaluate self.evaluator.metric_instances = {"mock_metric": failing_metric, "llm_judge": self.mock_llm_judge} field_config = FieldConfig(method="mock_metric") result = await self.evaluator.evaluate_tree( reference="ref", actual="act", config=field_config, path=["field"] ) assert isinstance(result, EvaluationScore) assert result.section_score is None assert result.error is not None assert result.error == expected_error_msg @pytest.mark.asyncio async def test_evaluate_tree_dynamic_discovery_reference_field_scenarios(self): """Test evaluate_tree handles all reference_field scenarios in dynamic discovery.""" mock_llm = MockLLMJudge(score=MOCK_LLM_JUDGE_SCORE) # Mock discovery returns different reference_field scenarios async def mock_discovery(reference_section, actual_section, unspecified_fields): return { "field_with_match": {"score": 0.9, "reference_field": "ref_field_exists"}, "field_with_missing_ref": {"score": 0.7, "reference_field": "ref_field_missing"}, "field_no_ref": {"score": 0.8, "reference_field": None}, "field_with_none_result": None, # LLM failed to score } mock_llm.evaluate_with_field_discovery = mock_discovery evaluator = ReportEvaluator( config=self.config, metric_instances={"mock_metric": self.mock_metric, "llm_judge": mock_llm}, object_store_client=self.mock_object_store, report_url_pattern=self.report_url_pattern, include_vlm_output=False, ) section_config = FieldConfig(method="average", allow_dynamic_field_discovery=True) reference = {"ref_field_exists": "ref_value"} actual = { "field_with_match": "act1", "field_with_missing_ref": "act2", "field_no_ref": "act3", "field_with_none_result": "act4", } result = await evaluator.evaluate_tree( reference=reference, actual=actual, config=section_config, path=["section"] ) assert isinstance(result, EvaluationScore) # Scenario 1: Field with matching reference in reference section assert result.field_scores["field_with_match"].section_score == 0.9 assert result.field_scores["field_with_match"].reference_value == "ref_value" # Scenario 2: Field with reference_field specified but not found in reference section assert result.field_scores["field_with_missing_ref"].section_score == 0.7 assert ( result.field_scores["field_with_missing_ref"].reference_value == "[no matching reference field: ref_field_missing]" ) # Scenario 3: Field with no reference_field assert result.field_scores["field_no_ref"].section_score == 0.8 assert ( result.field_scores["field_no_ref"].reference_value == "[no matching reference field found in LLM response]" ) # Scenario 4: LLM failed to score field (returns None) assert result.field_scores["field_with_none_result"].section_score is None assert result.field_scores["field_with_none_result"].error == "LLM failed to score this field during discovery" @pytest.mark.asyncio async def test_evaluate_tree_nested_sections(self): """Test evaluate_tree with nested sections verifies multi-level averaging.""" mock_metric_0_4 = MockMetric(score=0.4) mock_metric_0_6 = MockMetric(score=0.6) mock_metric_1_0 = MockMetric(score=1.0) evaluator = ReportEvaluator( config=self.config, metric_instances={ "metric_0_4": mock_metric_0_4, "metric_0_6": mock_metric_0_6, "metric_1_0": mock_metric_1_0, "llm_judge": self.mock_llm_judge, }, object_store_client=self.mock_object_store, report_url_pattern=self.report_url_pattern, include_vlm_output=False, ) nested_config = FieldConfig( method="average", fields={ "section1": FieldConfig( method="average", fields={ "field1": FieldConfig(method="metric_0_4"), "field2": FieldConfig(method="metric_0_6"), }, ), "section2": FieldConfig(method="metric_1_0"), }, ) reference = { "section1": {"field1": "ref1", "field2": "ref2"}, "section2": "ref3", } actual = { "section1": {"field1": "act1", "field2": "act2"}, "section2": "act3", } result = await evaluator.evaluate_tree(reference=reference, actual=actual, config=nested_config, path=["root"]) assert isinstance(result, EvaluationScore) assert len(result.field_scores) == 2 # Check section1 nested scores assert result.field_scores["section1"].field_scores["field1"].section_score == 0.4 assert result.field_scores["section1"].field_scores["field2"].section_score == 0.6 # section1 average assert result.field_scores["section1"].section_score == 0.5 # Check section2 score assert result.field_scores["section2"].section_score == 1.0 # Root level average assert result.section_score == 0.75 @pytest.mark.asyncio async def test_evaluate_tree_explicit_plus_dynamic_discovery(self): """Test section with both explicit fields and dynamic discovery enabled.""" mock_llm = MockLLMJudge(score=MOCK_LLM_JUDGE_SCORE) # Mock discovery for dynamic field async def mock_discovery(reference_section, actual_section, unspecified_fields): return { "surprise_field": {"score": 0.85, "reference_field": None}, } mock_llm.evaluate_with_field_discovery = mock_discovery evaluator = ReportEvaluator( config=self.config, metric_instances={"mock_metric": self.mock_metric, "llm_judge": mock_llm}, object_store_client=self.mock_object_store, report_url_pattern=self.report_url_pattern, include_vlm_output=False, ) section_config = FieldConfig( method="average", fields={"known_field": FieldConfig(method="mock_metric")}, allow_dynamic_field_discovery=True, ) reference = {"known_field": "ref1", "ref_only": "ref2"} actual = {"known_field": "act1", "surprise_field": "act2"} result = await evaluator.evaluate_tree( reference=reference, actual=actual, config=section_config, path=["section"] ) assert isinstance(result, EvaluationScore) # Should have explicit field scored by mock_metric assert "known_field" in result.field_scores assert result.field_scores["known_field"].method == "mock_metric" assert result.field_scores["known_field"].section_score == MOCK_METRIC_SCORE # Should have dynamic field scored by llm_judge with field discovery assert "surprise_field" in result.field_scores assert result.field_scores["surprise_field"].method == "llm_judge_with_field_discovery" assert result.field_scores["surprise_field"].section_score == 0.85 assert result.section_score == 0.825 @pytest.mark.asyncio async def test_score_value_with_env_vars(self): """Test _score_value expands environment variables embedded in strings.""" import os os.environ["TEST_VAR"] = "test_value" os.environ["ANOTHER_VAR"] = "another" mock_metric = AsyncMock(return_value=MOCK_METRIC_SCORE) self.evaluator.metric_instances["mock_metric"].evaluate = mock_metric await self.evaluator._score_value( reference="Expected value is $TEST_VAR and $ANOTHER_VAR here", actual="actual_value", method="mock_metric", path=["field"], ) call_args = mock_metric.call_args assert call_args[0][1] == "Expected value is test_value and another here" del os.environ["TEST_VAR"] del os.environ["ANOTHER_VAR"] @pytest.mark.asyncio async def test_evaluate_item_success(self): """Test evaluate_item with successful evaluation.""" # Create temp reference file reference_data = {"report": {"summary": "ref summary", "details": "ref details"}} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(reference_data, f) reference_path = f.name try: # Mock the fetch_and_parse_report generated_data = {"summary": "gen summary", "details": "gen details"} with patch( "vss_agents.evaluators.report_evaluator.evaluate._fetch_and_parse_report", AsyncMock(return_value=(generated_data, "report_123.md")), ): item = EvalInputItem( id="test_item", input_obj="test query", expected_output_obj=reference_path, output_obj="Here is the report: report_123.md", trajectory=[], expected_trajectory=[], full_dataset_entry={"id": "test_item", "evaluation_method": ["report"]}, ) result = await self.evaluator.evaluate_item(item) assert result.id == "test_item" assert result.score == MOCK_METRIC_SCORE assert isinstance(result.reasoning, dict) assert set(result.reasoning.keys()) == {"sections", "metadata"} assert set(result.reasoning["metadata"].keys()) == {"reference_file", "actual_file"} assert result.reasoning["metadata"]["actual_file"] == "report_123.md" assert reference_path in result.reasoning["metadata"]["reference_file"] finally: Path(reference_path).unlink() @pytest.mark.asyncio async def test_evaluate_item_error_handling(self): """Test evaluate_item handles errors gracefully.""" item = EvalInputItem( id="test_item", input_obj="test query", expected_output_obj="/nonexistent/path.json", output_obj="report content", trajectory=[], expected_trajectory=[], full_dataset_entry={"id": "test_item", "evaluation_method": ["report"]}, ) result = await self.evaluator.evaluate_item(item) assert result.id == "test_item" assert result.score is None assert isinstance(result.reasoning, dict) assert set(result.reasoning.keys()) == {"error"} assert isinstance(result.reasoning["error"], str) assert len(result.reasoning["error"]) > 0 @pytest.mark.asyncio async def test_evaluate_item_with_vlm_scoring(self): """Test evaluate_item includes vlm_field_score when enabled.""" mock_metric_0_6 = MockMetric(score=0.6) # vlm_field_1 mock_metric_0_8 = MockMetric(score=0.8) # vlm_field_2 mock_metric_1_0 = MockMetric(score=1.0) # other_field config = EvalMetricsConfig.from_dict( { "report": { "method": "average", "fields": { "vlm_field_1": {"method": "metric_0_6"}, "vlm_field_2": {"method": "metric_0_8"}, "other_field": {"method": "metric_1_0"}, }, } } ) evaluator = ReportEvaluator( config=config, metric_instances={ "metric_0_6": mock_metric_0_6, "metric_0_8": mock_metric_0_8, "metric_1_0": mock_metric_1_0, "llm_judge": self.mock_llm_judge, }, object_store_client=self.mock_object_store, report_url_pattern=self.report_url_pattern, include_vlm_output=True, vlm_related_fields=["vlm_field_1", "vlm_field_2"], ) reference_data = { "report": { "vlm_field_1": "ref1", "vlm_field_2": "ref2", "other_field": "ref3", } } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(reference_data, f) reference_path = f.name try: generated_data = { "vlm_field_1": "gen1", "vlm_field_2": "gen2", "other_field": "gen3", } with patch( "vss_agents.evaluators.report_evaluator.evaluate._fetch_and_parse_report", AsyncMock(return_value=(generated_data, "report_123.md")), ): item = EvalInputItem( id="test_item", input_obj="test query", expected_output_obj=reference_path, output_obj="Here is the report: report_123.md", trajectory=[], expected_trajectory=[], full_dataset_entry={"id": "test_item", "evaluation_method": ["report"]}, ) result = await evaluator.evaluate_item(item) # Overall score should be average of all 3 fields: (0.6 + 0.8 + 1.0) / 3 = 0.8 assert result.score == pytest.approx(0.8) # VLM score should be average of only vlm_field_1 and vlm_field_2: (0.6 + 0.8) / 2 = 0.7 assert result.vlm_field_score == pytest.approx(0.7) finally: Path(reference_path).unlink() @pytest.mark.asyncio async def test_evaluate_item_with_vlm_scoring_disabled(self): """Test evaluate_item doesn't include vlm_field_score when disabled.""" evaluator = ReportEvaluator( config=self.config, metric_instances=self.metric_instances, object_store_client=self.mock_object_store, report_url_pattern=self.report_url_pattern, include_vlm_output=False, vlm_related_fields=None, ) # Create temp reference file reference_data = {"report": {"summary": "ref", "details": "ref"}} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(reference_data, f) reference_path = f.name try: generated_data = {"summary": "gen", "details": "gen"} with patch( "vss_agents.evaluators.report_evaluator.evaluate._fetch_and_parse_report", AsyncMock(return_value=(generated_data, "report_123.md")), ): item = EvalInputItem( id="test_item", input_obj="test query", expected_output_obj=reference_path, output_obj="Here is the report: report_123.md", trajectory=[], expected_trajectory=[], full_dataset_entry={"id": "test_item", "evaluation_method": ["report"]}, ) result = await evaluator.evaluate_item(item) assert result.score == MOCK_METRIC_SCORE # VLM score should be None when disabled assert result.vlm_field_score is None finally: Path(reference_path).unlink() @pytest.mark.asyncio async def test_evaluate_item_vlm_scoring_treats_none_as_zero(self): """Test VLM scoring treats None scores as 0.0 in average.""" mock_metric_0_6 = MockMetric(score=0.6) mock_metric_none = MockMetric(score=None) mock_metric_1_0 = MockMetric(score=1.0) config = EvalMetricsConfig.from_dict( { "report": { "method": "average", "fields": { "vlm_field_1": {"method": "metric_0_6"}, "vlm_field_2": {"method": "metric_none"}, "vlm_field_3": {"method": "metric_1_0"}, }, } } ) evaluator = ReportEvaluator( config=config, metric_instances={ "metric_0_6": mock_metric_0_6, "metric_none": mock_metric_none, "metric_1_0": mock_metric_1_0, "llm_judge": self.mock_llm_judge, }, object_store_client=self.mock_object_store, report_url_pattern=self.report_url_pattern, include_vlm_output=True, vlm_related_fields=["vlm_field_1", "vlm_field_2", "vlm_field_3"], ) reference_data = { "report": { "vlm_field_1": "ref1", "vlm_field_2": "ref2", "vlm_field_3": "ref3", } } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(reference_data, f) reference_path = f.name try: generated_data = { "vlm_field_1": "gen1", "vlm_field_2": "gen2", "vlm_field_3": "gen3", } with patch( "vss_agents.evaluators.report_evaluator.evaluate._fetch_and_parse_report", AsyncMock(return_value=(generated_data, "report_123.md")), ): item = EvalInputItem( id="test_item", input_obj="test query", expected_output_obj=reference_path, output_obj="Here is the report: report_123.md", trajectory=[], expected_trajectory=[], full_dataset_entry={"id": "test_item", "evaluation_method": ["report"]}, ) result = await evaluator.evaluate_item(item) # VLM score should treat None as 0.0: (0.6 + 0.0 + 1.0) / 3 = 0.533... # vlm_field_2 with None is treated as 0.0 assert result.vlm_field_score == pytest.approx(0.533, rel=0.01) finally: Path(reference_path).unlink() ================================================ FILE: agent/tests/unit_test/evaluators/test_utils.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for evaluators/utils module.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from nat.eval.evaluator.evaluator_model import EvalInputItem import pytest from vss_agents.evaluators.utils import ScoreOutputParser from vss_agents.evaluators.utils import compute_item_latency from vss_agents.evaluators.utils import invoke_llm_with_retry from vss_agents.evaluators.utils import should_evaluate from vss_agents.evaluators.utils import strip_agent_think_tags class TestShouldEvaluate: """Test should_evaluate function.""" def test_missing_full_dataset_entry(self): item = EvalInputItem( id="test_001", input_obj="question", output_obj="answer", expected_output_obj="expected", full_dataset_entry={"evaluation_method": ["qa"]}, ) # Remove the attribute to simulate missing item.full_dataset_entry = None with pytest.raises(ValueError, match="missing full_dataset_entry"): should_evaluate(item, "qa") def test_missing_evaluation_method(self): item = EvalInputItem( id="test_002", input_obj="question", output_obj="answer", expected_output_obj="expected", full_dataset_entry={"other_field": "value"}, # No evaluation_method ) with pytest.raises(ValueError, match="missing required 'evaluation_method'"): should_evaluate(item, "qa") def test_evaluation_method_not_list(self): item = EvalInputItem( id="test_003", input_obj="question", output_obj="answer", expected_output_obj="expected", full_dataset_entry={"evaluation_method": "qa"}, # String, not list ) with pytest.raises(ValueError, match="Must be a list"): should_evaluate(item, "qa") def test_evaluator_type_in_list(self): item = EvalInputItem( id="test_004", input_obj="question", output_obj="answer", expected_output_obj="expected", full_dataset_entry={"evaluation_method": ["qa", "trajectory"]}, ) assert should_evaluate(item, "qa") is True assert should_evaluate(item, "trajectory") is True def test_evaluator_type_not_in_list(self): item = EvalInputItem( id="test_005", input_obj="question", output_obj="answer", expected_output_obj="expected", full_dataset_entry={"evaluation_method": ["trajectory"]}, ) assert should_evaluate(item, "qa") is False def test_empty_evaluation_method_list(self): item = EvalInputItem( id="test_006", input_obj="question", output_obj="answer", expected_output_obj="expected", full_dataset_entry={"evaluation_method": []}, ) assert should_evaluate(item, "qa") is False class TestComputeItemLatency: """Test compute_item_latency function.""" def _make_item(self, trajectory_timestamps=None): item = EvalInputItem( id="test", input_obj="q", output_obj=None, expected_output_obj=None, full_dataset_entry={}, ) if trajectory_timestamps is not None: item.trajectory = [MagicMock(event_timestamp=ts) for ts in trajectory_timestamps] else: item.trajectory = [] return item def test_computes_latency_from_timestamps(self): item = self._make_item([10.0, 12.5, 15.0]) assert compute_item_latency(item) == 5.0 def test_single_timestamp_returns_zero(self): item = self._make_item([10.0]) assert compute_item_latency(item) == 0.0 def test_two_timestamps(self): item = self._make_item([5.0, 8.123]) assert compute_item_latency(item) == 3.123 def test_returns_none_for_empty_trajectory(self): item = self._make_item([]) assert compute_item_latency(item) is None def test_returns_none_for_no_trajectory(self): item = self._make_item() assert compute_item_latency(item) is None def test_rounds_to_3_decimals(self): item = self._make_item([1.0, 1.12356]) assert compute_item_latency(item) == 0.124 class TestStripAgentThinkTags: """Test strip_agent_think_tags function.""" def test_no_tags(self): text = "This is normal text without tags." assert strip_agent_think_tags(text) == text def test_single_tag(self): text = "Some thinkingThe answer is 42." assert strip_agent_think_tags(text) == "The answer is 42." def test_multiple_tags(self): text = "Think 1Part 1Think 2Part 2" assert strip_agent_think_tags(text) == "Part 1Part 2" def test_multiline_tags(self): text = """ This is multiline thinking The final answer.""" assert strip_agent_think_tags(text) == "The final answer." def test_empty_string(self): assert strip_agent_think_tags("") == "" def test_none_input(self): assert strip_agent_think_tags(None) == "" def test_only_tags(self): text = "Only thinking here" assert strip_agent_think_tags(text) == "" class TestScoreOutputParser: """Test ScoreOutputParser class.""" def test_parse_simple_score(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "0.85" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} result = parser.parse(mock_response) assert result["score"] == 0.85 assert result["reasoning"] == "" def test_parse_score_with_text(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "The score is 0.75 based on analysis" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} result = parser.parse(mock_response) assert result["score"] == 0.75 def test_parse_with_think_tags(self): parser = ScoreOutputParser() mock_response = MagicMock() mock_response.content = "My reasoning0.8" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} result = parser.parse(mock_response) assert result["score"] == 0.8 assert "My reasoning" in result["reasoning"] class TestInvokeLLMWithRetry: """Test invoke_llm_with_retry function.""" @pytest.mark.asyncio async def test_successful_invocation(self): mock_response = MagicMock() mock_response.content = "0.9" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} mock_llm = AsyncMock() mock_llm.model_name = "test-model" mock_llm.ainvoke = AsyncMock(return_value=mock_response) mock_llm.bind = MagicMock(return_value=mock_llm) parser = ScoreOutputParser() def build_reasoning(eval_result): return {"reasoning": eval_result["reasoning"]} result = await invoke_llm_with_retry( llm=mock_llm, prompt_text="Test prompt", output_parser=parser, item_id="test_001", max_retries=2, evaluator_name="Test Evaluator", question_preview="Test question...", build_reasoning=build_reasoning, ) assert result.id == "test_001" assert result.score == 0.9 mock_llm.ainvoke.assert_called_once() @pytest.mark.asyncio async def test_retry_on_failure(self): mock_response = MagicMock() mock_response.content = "0.8" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} mock_llm = AsyncMock() mock_llm.model_name = "test-model" # First call fails, second succeeds mock_llm.ainvoke = AsyncMock(side_effect=[Exception("Temporary error"), mock_response]) mock_llm.bind = MagicMock(return_value=mock_llm) parser = ScoreOutputParser() def build_reasoning(eval_result): return {"reasoning": eval_result["reasoning"]} result = await invoke_llm_with_retry( llm=mock_llm, prompt_text="Test prompt", output_parser=parser, item_id="test_002", max_retries=2, evaluator_name="Test Evaluator", question_preview="Test question...", build_reasoning=build_reasoning, ) assert result.id == "test_002" assert result.score == 0.8 assert mock_llm.ainvoke.call_count == 2 @pytest.mark.asyncio async def test_exhausted_retries(self): mock_llm = AsyncMock() mock_llm.model_name = "test-model" mock_llm.ainvoke = AsyncMock(side_effect=Exception("Persistent error")) mock_llm.bind = MagicMock(return_value=mock_llm) parser = ScoreOutputParser() def build_reasoning(eval_result): return {"reasoning": eval_result["reasoning"]} result = await invoke_llm_with_retry( llm=mock_llm, prompt_text="Test prompt", output_parser=parser, item_id="test_003", max_retries=1, evaluator_name="Test Evaluator", question_preview="Test question...", build_reasoning=build_reasoning, ) assert result.id == "test_003" assert result.score == 0.0 assert "Error evaluating" in result.reasoning assert mock_llm.ainvoke.call_count == 2 # Initial + 1 retry @pytest.mark.asyncio async def test_llm_judge_reasoning_disabled(self): mock_response = MagicMock() mock_response.content = "0.7" mock_response.reasoning_content = None mock_response.additional_kwargs = {} mock_response.response_metadata = {} mock_llm = AsyncMock() mock_llm.model_name = "test-model" mock_llm.ainvoke = AsyncMock(return_value=mock_response) mock_llm.bind = MagicMock(return_value=mock_llm) parser = ScoreOutputParser() def build_reasoning(eval_result): return {"reasoning": eval_result["reasoning"]} result = await invoke_llm_with_retry( llm=mock_llm, prompt_text="Test prompt", output_parser=parser, item_id="test_004", max_retries=0, evaluator_name="Test Evaluator", question_preview="Test question...", build_reasoning=build_reasoning, llm_judge_reasoning=False, ) assert result.id == "test_004" assert result.score == 0.7 ================================================ FILE: agent/tests/unit_test/test_prompt.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/prompt.py.""" from vss_agents.prompt import INIT_SUMMARIZE_PROMPT from vss_agents.prompt import VIDEO_FRAME_TIMESTAMP_PROMPT from vss_agents.prompt import VLM_FORMAT_INSTRUCTION from vss_agents.prompt import VLM_PROMPT_EXAMPLES from vss_agents.prompt import VSS_SUMMARIZE_PROMPT class TestVlmPromptExamples: """Tests for VLM_PROMPT_EXAMPLES constant.""" def test_examples_is_list(self): """Test that VLM_PROMPT_EXAMPLES is a list.""" assert isinstance(VLM_PROMPT_EXAMPLES, list) def test_examples_not_empty(self): """Test that VLM_PROMPT_EXAMPLES has examples.""" assert len(VLM_PROMPT_EXAMPLES) > 0 class TestVlmFormatInstruction: """Tests for VLM_FORMAT_INSTRUCTION constant.""" def test_instruction_is_string(self): """Test that VLM_FORMAT_INSTRUCTION is a string.""" assert isinstance(VLM_FORMAT_INSTRUCTION, str) def test_instruction_mentions_timestamp(self): """Test that instruction mentions timestamp.""" assert "timestamp" in VLM_FORMAT_INSTRUCTION.lower() class TestInitSummarizePrompt: """Tests for INIT_SUMMARIZE_PROMPT constant.""" def test_prompt_is_dict(self): """Test that INIT_SUMMARIZE_PROMPT is a dict.""" assert isinstance(INIT_SUMMARIZE_PROMPT, dict) def test_prompt_has_required_keys(self): """Test that INIT_SUMMARIZE_PROMPT has required keys.""" assert "prompt" in INIT_SUMMARIZE_PROMPT assert "caption_summarization_prompt" in INIT_SUMMARIZE_PROMPT assert "summary_aggregation_prompt" in INIT_SUMMARIZE_PROMPT class TestVideoFrameTimestampPrompt: """Tests for VIDEO_FRAME_TIMESTAMP_PROMPT constant.""" def test_prompt_is_string(self): """Test that VIDEO_FRAME_TIMESTAMP_PROMPT is a string.""" assert isinstance(VIDEO_FRAME_TIMESTAMP_PROMPT, str) class TestVssSummarizePrompt: """Tests for VSS_SUMMARIZE_PROMPT constant.""" def test_prompt_is_string(self): """Test that VSS_SUMMARIZE_PROMPT is a string.""" assert isinstance(VSS_SUMMARIZE_PROMPT, str) def test_prompt_contains_placeholders(self): """Test that prompt contains expected placeholders.""" assert "{user_query}" in VSS_SUMMARIZE_PROMPT assert "{user_intent}" in VSS_SUMMARIZE_PROMPT ================================================ FILE: agent/tests/unit_test/test_sitecustomize.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for sitecustomize.py.""" from unittest.mock import MagicMock from unittest.mock import patch class TestSiteCustomize: """Tests for sitecustomize module.""" def test_load_env_file_with_dotenv(self, tmp_path): """Test _load_env_file with dotenv available.""" # Create a temporary .env file env_file = tmp_path / ".env" env_file.write_text("TEST_VAR=test_value") from sitecustomize import _load_env_file with patch("sitecustomize.load_dotenv") as mock_load_dotenv: _load_env_file(env_file) mock_load_dotenv.assert_called_once_with(env_file, override=False) def test_load_env_file_without_dotenv(self, tmp_path): """Test _load_env_file when dotenv is not available.""" env_file = tmp_path / ".env" env_file.write_text("TEST_VAR=test_value") from sitecustomize import _load_env_file with patch("sitecustomize.load_dotenv", None): # Should not raise, just log a warning _load_env_file(env_file) def test_load_env_file_nonexistent(self, tmp_path): """Test _load_env_file with nonexistent file.""" env_file = tmp_path / "nonexistent.env" from sitecustomize import _load_env_file with patch("sitecustomize.load_dotenv") as mock_load_dotenv: _load_env_file(env_file) # Should not call load_dotenv for nonexistent file mock_load_dotenv.assert_not_called() def test_auto_load_env_files_no_pointer(self, tmp_path): """Test _auto_load_env_files when .env_file pointer doesn't exist.""" from sitecustomize import _auto_load_env_files with patch("sitecustomize.Path") as mock_path: mock_path.return_value.resolve.return_value.parent.parent = tmp_path mock_env_pointer = MagicMock() mock_env_pointer.is_file.return_value = False # Should not raise, just log info _auto_load_env_files() def test_auto_load_env_files_with_pointer(self, tmp_path): """Test _auto_load_env_files when .env_file pointer exists.""" # Create a temp .env file env_file = tmp_path / ".env" env_file.write_text("TEST_VAR=test_value") # Create the pointer file pointer_file = tmp_path / ".env_file" pointer_file.write_text(str(env_file)) with patch("sitecustomize.Path") as mock_path_class: mock_file_path = MagicMock() mock_file_path.resolve.return_value.parent.parent = tmp_path mock_env_pointer = MagicMock() mock_env_pointer.is_file.return_value = True mock_env_pointer.read_text.return_value = str(env_file) def path_side_effect(arg=None): if arg is None: return mock_file_path if str(arg) == str(tmp_path / ".env_file"): return mock_env_pointer return MagicMock(is_file=MagicMock(return_value=False)) mock_path_class.side_effect = path_side_effect mock_path_class.return_value = mock_file_path def test_auto_load_env_files_empty_pointer(self, tmp_path): """Test _auto_load_env_files when .env_file is empty.""" # Create empty pointer file pointer_file = tmp_path / ".env_file" pointer_file.write_text("") # Should not raise, just log a warning # Note: This test verifies the code handles edge cases gracefully ================================================ FILE: agent/tests/unit_test/tools/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vss_agents.tools package.""" ================================================ FILE: agent/tests/unit_test/tools/test_build_vst_url.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for build_vst_url.""" import pytest from vss_agents.tools.vst.utils import build_vst_url class TestBuildVstUrl: """Tests for the build_vst_url helper.""" def test_replaces_scheme_and_host(self): result = build_vst_url( "http://10.0.1.1:30888", "http://232.2.2.34:22324/vst/api/v1/storage/file.mp4", ) assert result == "http://10.0.1.1:30888/vst/api/v1/storage/file.mp4" def test_preserves_path_query_fragment(self): result = build_vst_url( "http://10.0.1.1:30888", "https://proxy.example.com:443/vst/api/v1/clip?start=0&end=10#section", ) assert result == "http://10.0.1.1:30888/vst/api/v1/clip?start=0&end=10#section" def test_https_base_url(self): result = build_vst_url( "https://internal:8443", "http://external:9999/vst/storage/file.mp4", ) assert result == "https://internal:8443/vst/storage/file.mp4" def test_base_url_trailing_slash(self): result = build_vst_url( "http://10.0.1.1:30888/", "http://other:1234/vst/api/v1/resource", ) assert result == "http://10.0.1.1:30888/vst/api/v1/resource" def test_no_path(self): result = build_vst_url( "http://10.0.1.1:30888", "http://external:9999", ) assert result == "http://10.0.1.1:30888" def test_root_path(self): result = build_vst_url( "http://10.0.1.1:30888", "http://external:9999/", ) assert result == "http://10.0.1.1:30888/" def test_same_host(self): result = build_vst_url( "http://10.0.1.1:30888", "http://10.0.1.1:30888/vst/api/v1/storage/file.mp4", ) assert result == "http://10.0.1.1:30888/vst/api/v1/storage/file.mp4" @pytest.mark.parametrize( "base,url,expected", [ ( "http://localhost:30888", "http://1.2.3.4:30888/vst/api/v1/clip", "http://localhost:30888/vst/api/v1/clip", ), ( "http://10.0.0.5:30888", "https://brev-proxy.example.com/vst/storage/video.mp4", "http://10.0.0.5:30888/vst/storage/video.mp4", ), ], ) def test_parametrized(self, base, url, expected): assert build_vst_url(base, url) == expected ================================================ FILE: agent/tests/unit_test/tools/test_chart_generator.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for chart_generator module.""" from pydantic import ValidationError import pytest from vss_agents.tools.chart_generator import BarChartData from vss_agents.tools.chart_generator import ChartData from vss_agents.tools.chart_generator import ChartFileFormat from vss_agents.tools.chart_generator import ChartGeneratorConfig from vss_agents.tools.chart_generator import ChartGeneratorInput from vss_agents.tools.chart_generator import ChartGenExecOutput from vss_agents.tools.chart_generator import ChartType from vss_agents.tools.chart_generator import PieChartData from vss_agents.tools.chart_generator import _str_input_converter from vss_agents.tools.chart_generator import convert_to_format from vss_agents.tools.chart_generator import plot_bar_chart from vss_agents.tools.chart_generator import plot_pie_chart class TestChartType: """Test ChartType enum.""" def test_chart_type_values(self): assert ChartType.BAR == "bar" assert ChartType.PIE == "pie" def test_chart_type_all_values(self): assert len(ChartType) == 2 class TestChartFileFormat: """Test ChartFileFormat enum.""" def test_chart_file_format_values(self): assert ChartFileFormat.PNG == "png" assert ChartFileFormat.SVG == "svg" assert ChartFileFormat.JPEG == "jpeg" class TestChartData: """Test ChartData base model.""" def test_chart_data_defaults(self): data = ChartData() assert data.chart_file_format == ChartFileFormat.PNG assert data.title == "" def test_chart_data_with_values(self): data = ChartData(chart_file_format=ChartFileFormat.SVG, title="Test Chart") assert data.chart_file_format == ChartFileFormat.SVG assert data.title == "Test Chart" class TestBarChartData: """Test BarChartData model.""" def test_bar_chart_data_creation(self): data = BarChartData( x_categories=["A", "B", "C"], series={"values": [10.0, 20.0, 30.0]}, ) assert data.x_categories == ["A", "B", "C"] assert data.series == {"values": [10.0, 20.0, 30.0]} assert data.x_label == "" assert data.y_label == "" def test_bar_chart_data_full(self): data = BarChartData( x_categories=["Jan", "Feb", "Mar"], series={"sales": [100.0, 150.0, 200.0], "expenses": [80.0, 90.0, 100.0]}, x_label="Month", y_label="Amount", title="Monthly Report", chart_file_format=ChartFileFormat.PNG, ) assert data.x_label == "Month" assert data.y_label == "Amount" assert data.title == "Monthly Report" assert len(data.series) == 2 def test_bar_chart_data_empty_series(self): data = BarChartData( x_categories=["A"], series={}, ) assert data.series == {} class TestPieChartData: """Test PieChartData model.""" def test_pie_chart_data_creation(self): data = PieChartData( sizes=[30.0, 20.0, 50.0], labels=["A", "B", "C"], ) assert data.sizes == [30.0, 20.0, 50.0] assert data.labels == ["A", "B", "C"] assert data.title == "" def test_pie_chart_data_with_title(self): data = PieChartData( sizes=[25.0, 25.0, 50.0], labels=["X", "Y", "Z"], title="Distribution", ) assert data.title == "Distribution" class TestChartGeneratorConfig: """Test ChartGeneratorConfig model.""" def test_config_defaults(self): config = ChartGeneratorConfig() assert config.object_store_name is None assert str(config.object_store_base_url) == "http://localhost:8000/static/" def test_config_with_custom_url(self): config = ChartGeneratorConfig(object_store_base_url="http://example.com/charts") # The validator adds trailing slash assert str(config.object_store_base_url).endswith("/") def test_config_url_with_trailing_slash(self): config = ChartGeneratorConfig(object_store_base_url="http://example.com/charts/") assert str(config.object_store_base_url) == "http://example.com/charts/" def test_config_url_with_query_fails(self): with pytest.raises(ValidationError): ChartGeneratorConfig(object_store_base_url="http://example.com/charts?query=1") def test_config_url_with_fragment_fails(self): with pytest.raises(ValidationError): ChartGeneratorConfig(object_store_base_url="http://example.com/charts#section") def test_config_url_pointing_to_file_fails(self): with pytest.raises(ValidationError): ChartGeneratorConfig(object_store_base_url="http://example.com/charts/image.png") class TestChartGeneratorInput: """Test ChartGeneratorInput model.""" def test_input_with_bar_chart(self): bar_data = BarChartData( x_categories=["A", "B"], series={"data": [1.0, 2.0]}, ) input_data = ChartGeneratorInput(charts_data=[bar_data]) assert len(input_data.charts_data) == 1 assert input_data.output_dir is None assert input_data.file_prefix == "chart_" def test_input_with_pie_chart(self): pie_data = PieChartData( sizes=[50.0, 50.0], labels=["Yes", "No"], ) input_data = ChartGeneratorInput(charts_data=[pie_data]) assert len(input_data.charts_data) == 1 def test_input_with_mixed_charts(self): bar_data = BarChartData(x_categories=["A"], series={"x": [1.0]}) pie_data = PieChartData(sizes=[100.0], labels=["All"]) input_data = ChartGeneratorInput(charts_data=[bar_data, pie_data]) assert len(input_data.charts_data) == 2 def test_input_output_dir_sanitization(self): input_data = ChartGeneratorInput( charts_data=[], output_dir="charts/subfolder/../other", ) # Should be normalized assert ".." not in str(input_data.output_dir) def test_input_output_dir_absolute_path(self): input_data = ChartGeneratorInput( charts_data=[], output_dir="/absolute/path", ) assert input_data.output_dir is not None def test_input_output_dir_none(self): input_data = ChartGeneratorInput(charts_data=[]) assert input_data.output_dir is None class TestChartGenExecOutput: """Test ChartGenExecOutput model.""" def test_output_success(self): output = ChartGenExecOutput( success=True, error_message=None, object_store_key="charts/chart_0.png", ) assert output.success is True assert output.error_message is None assert output.object_store_key == "charts/chart_0.png" def test_output_failure(self): output = ChartGenExecOutput( success=False, error_message="Generation failed", ) assert output.success is False assert output.error_message == "Generation failed" assert output.object_store_key is None class TestPlotBarChart: """Test plot_bar_chart function.""" def test_plot_bar_chart_single_series(self): data = BarChartData( x_categories=["A", "B", "C"], series={"values": [10.0, 20.0, 30.0]}, title="Test Bar Chart", x_label="Categories", y_label="Values", ) fig = plot_bar_chart(data) assert fig is not None # Cleanup import matplotlib.pyplot as plt plt.close(fig) def test_plot_bar_chart_multiple_series(self): data = BarChartData( x_categories=["Q1", "Q2", "Q3", "Q4"], series={ "2023": [100.0, 120.0, 150.0, 180.0], "2024": [110.0, 130.0, 160.0, 200.0], }, title="Quarterly Sales Comparison", ) fig = plot_bar_chart(data) assert fig is not None import matplotlib.pyplot as plt plt.close(fig) def test_plot_bar_chart_empty_series(self): data = BarChartData( x_categories=["A", "B"], series={"empty": [0.0, 0.0]}, # Use empty values instead of empty dict ) fig = plot_bar_chart(data) assert fig is not None import matplotlib.pyplot as plt plt.close(fig) class TestPlotPieChart: """Test plot_pie_chart function.""" def test_plot_pie_chart_basic(self): data = PieChartData( sizes=[30.0, 20.0, 50.0], labels=["A", "B", "C"], title="Distribution", ) fig = plot_pie_chart(data) assert fig is not None import matplotlib.pyplot as plt plt.close(fig) def test_plot_pie_chart_no_title(self): data = PieChartData( sizes=[50.0, 50.0], labels=["Yes", "No"], ) fig = plot_pie_chart(data) assert fig is not None import matplotlib.pyplot as plt plt.close(fig) class TestConvertToFormat: """Test convert_to_format function.""" def test_convert_to_png(self): data = BarChartData( x_categories=["A"], series={"data": [1.0]}, ) fig = plot_bar_chart(data) result = convert_to_format(fig, ChartFileFormat.PNG) assert isinstance(result, bytes) assert len(result) > 0 # PNG files start with specific bytes assert result[:4] == b"\x89PNG" import matplotlib.pyplot as plt plt.close(fig) def test_convert_to_svg(self): data = PieChartData( sizes=[100.0], labels=["All"], ) fig = plot_pie_chart(data) result = convert_to_format(fig, ChartFileFormat.SVG) assert isinstance(result, bytes) assert b" 0 plt.close(fig) def test_convert_to_svg(self): data = BarChartData(x_categories=["A"], series={"s": [1]}) fig = plot_bar_chart(data) result = convert_to_format(fig, ChartFileFormat.SVG) assert isinstance(result, bytes) assert len(result) > 0 plt.close(fig) class TestChartGeneratorConfig: """Test ChartGeneratorConfig model.""" def test_defaults(self): config = ChartGeneratorConfig() assert config.object_store_name is None assert "localhost" in str(config.object_store_base_url) def test_custom_url(self): config = ChartGeneratorConfig(object_store_base_url="http://storage.example.com/charts/") assert "storage.example.com" in str(config.object_store_base_url) def test_url_with_query_raises(self): with pytest.raises(ValidationError): ChartGeneratorConfig(object_store_base_url="http://example.com/path?q=1") def test_url_with_fragment_raises(self): with pytest.raises(ValidationError): ChartGeneratorConfig(object_store_base_url="http://example.com/path#frag") def test_url_pointing_to_file_raises(self): with pytest.raises(ValidationError): ChartGeneratorConfig(object_store_base_url="http://example.com/file.png") def test_url_normalization(self): config = ChartGeneratorConfig(object_store_base_url="http://example.com/charts") assert str(config.object_store_base_url).endswith("/") class TestChartGeneratorInput: """Test ChartGeneratorInput model.""" def test_basic(self): bar = BarChartData(x_categories=["A"], series={"s": [1]}) inp = ChartGeneratorInput(charts_data=[bar]) assert len(inp.charts_data) == 1 assert inp.output_dir is None assert inp.file_prefix == "chart_" def test_output_dir_sanitized(self): bar = BarChartData(x_categories=["A"], series={"s": [1]}) inp = ChartGeneratorInput(charts_data=[bar], output_dir="relative/path") assert ".." not in (inp.output_dir or "") def test_output_dir_none(self): bar = BarChartData(x_categories=["A"], series={"s": [1]}) inp = ChartGeneratorInput(charts_data=[bar], output_dir=None) assert inp.output_dir is None class TestChartGenExecOutput: """Test ChartGenExecOutput model.""" def test_success(self): output = ChartGenExecOutput( success=True, error_message=None, object_store_key="charts/chart_0.png", ) assert output.success is True assert output.object_store_key == "charts/chart_0.png" def test_failure(self): output = ChartGenExecOutput( success=False, error_message="Failed to generate", ) assert output.success is False assert output.error_message == "Failed to generate" class TestStrInputConverter: """Test _str_input_converter function.""" def test_valid_json(self): bar_data = {"x_categories": ["A"], "series": {"s": [1]}} input_json = json.dumps({"charts_data": [bar_data]}) result = _str_input_converter(input_json) assert len(result.charts_data) == 1 def test_invalid_json_raises(self): with pytest.raises(Exception): _str_input_converter("not json") class TestChatRequestInputConverter: """Test _chat_request_input_converter function.""" def test_valid_request(self): bar_data = {"x_categories": ["A"], "series": {"s": [1]}} content = json.dumps({"charts_data": [bar_data]}) mock_message = MagicMock() mock_message.content = content mock_request = MagicMock() mock_request.messages = [mock_message] result = _chat_request_input_converter(mock_request) assert len(result.charts_data) == 1 def test_invalid_content_raises(self): mock_message = MagicMock() mock_message.content = "not valid json" mock_request = MagicMock() mock_request.messages = [mock_message] with pytest.raises(Exception): _chat_request_input_converter(mock_request) ================================================ FILE: agent/tests/unit_test/tools/test_chart_generator_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for chart_generator inner function via generator invocation.""" from unittest.mock import AsyncMock import matplotlib.pyplot as plt import pytest from vss_agents.tools.chart_generator import BarChartData from vss_agents.tools.chart_generator import ChartGeneratorConfig from vss_agents.tools.chart_generator import ChartGeneratorInput from vss_agents.tools.chart_generator import PieChartData from vss_agents.tools.chart_generator import chart_generator class TestChartGeneratorInner: """Test the inner generate_chart function.""" @pytest.fixture def config(self): return ChartGeneratorConfig( object_store_name="test_store", object_store_base_url="http://localhost:8000/static/", ) @pytest.fixture def mock_builder(self): builder = AsyncMock() mock_object_store = AsyncMock() mock_object_store.upsert_object.return_value = None builder.get_object_store_client.return_value = mock_object_store return builder @pytest.mark.asyncio async def test_generate_bar_chart(self, config, mock_builder): gen = chart_generator.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn bar_data = BarChartData(x_categories=["A", "B"], series={"count": [10, 20]}, title="Test") inp = ChartGeneratorInput(charts_data=[bar_data], output_dir="charts") result = await inner_fn(inp) assert len(result) == 1 assert result[0].success is True assert result[0].object_store_key is not None plt.close("all") @pytest.mark.asyncio async def test_generate_pie_chart(self, config, mock_builder): gen = chart_generator.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn pie_data = PieChartData(sizes=[30, 70], labels=["A", "B"], title="Pie") inp = ChartGeneratorInput(charts_data=[pie_data], output_dir="charts") result = await inner_fn(inp) assert len(result) == 1 assert result[0].success is True plt.close("all") @pytest.mark.asyncio async def test_generate_multiple_charts(self, config, mock_builder): gen = chart_generator.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn bar_data = BarChartData(x_categories=["X"], series={"s": [1]}) pie_data = PieChartData(sizes=[50, 50], labels=["A", "B"]) inp = ChartGeneratorInput(charts_data=[bar_data, pie_data], output_dir="charts") result = await inner_fn(inp) assert len(result) == 2 assert all(r.success for r in result) plt.close("all") @pytest.mark.asyncio async def test_no_object_store_raises(self, mock_builder): config = ChartGeneratorConfig() # No object_store_name gen = chart_generator.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn bar_data = BarChartData(x_categories=["A"], series={"s": [1]}) inp = ChartGeneratorInput(charts_data=[bar_data]) with pytest.raises(RuntimeError, match="Failed to generate chart"): await inner_fn(inp) plt.close("all") @pytest.mark.asyncio async def test_output_converter(self, config, mock_builder): gen = chart_generator.__wrapped__(config, mock_builder) function_info = await gen.__anext__() assert function_info.converters is not None assert len(function_info.converters) >= 3 ================================================ FILE: agent/tests/unit_test/tools/test_code_executor.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for code_executor module.""" from unittest.mock import patch from pydantic import ValidationError import pytest from vss_agents.tools.code_executor.docker_backend import cleanup_docker_resources from vss_agents.tools.code_executor.python_executor import CodeExecutorConfig from vss_agents.tools.code_executor.python_executor import CodeExecutorInput from vss_agents.tools.code_executor.python_executor import CodeExecutorOutput class TestCodeExecutorConfig: """Test CodeExecutorConfig model.""" def test_config_creation(self): config = CodeExecutorConfig( base_image="python:3.11-slim", language_packages=["numpy", "pandas"], ) assert config.backend == "docker" assert config.gpu is False assert config.base_image == "python:3.11-slim" assert config.language_packages == ["numpy", "pandas"] def test_config_with_gpu(self): config = CodeExecutorConfig( base_image="python:3.11", language_packages=[], gpu=True, ) assert config.gpu is True def test_config_missing_base_image(self): with pytest.raises(ValidationError): CodeExecutorConfig(language_packages=[]) def test_config_missing_language_packages(self): with pytest.raises(ValidationError): CodeExecutorConfig(base_image="python:3.11") def test_config_empty_packages(self): config = CodeExecutorConfig( base_image="python:3.11", language_packages=[], ) assert config.language_packages == [] class TestCodeExecutorInput: """Test CodeExecutorInput model.""" def test_input_with_code(self): input_data = CodeExecutorInput( code="print('hello')", files={}, ) assert input_data.code == "print('hello')" assert input_data.files == {} def test_input_with_files(self): input_data = CodeExecutorInput( code="import data", files={"data.py": "x = 42"}, ) assert input_data.files == {"data.py": "x = 42"} def test_input_none_code(self): input_data = CodeExecutorInput( code=None, files={}, ) assert input_data.code is None def test_input_multiple_files(self): input_data = CodeExecutorInput( code="main code", files={ "utils.py": "def helper(): pass", "config.py": "DEBUG = True", "data.json": '{"key": "value"}', }, ) assert len(input_data.files) == 3 class TestCodeExecutorOutput: """Test CodeExecutorOutput model.""" def test_output_success(self): output = CodeExecutorOutput(message="Hello, World!") assert output.message == "Hello, World!" def test_output_error(self): output = CodeExecutorOutput(message="Error: {'exit_code': 1, 'stderr': 'NameError'}") assert "Error" in output.message def test_output_empty_message(self): output = CodeExecutorOutput(message="") assert output.message == "" def test_output_multiline(self): output = CodeExecutorOutput(message="Line 1\nLine 2\nLine 3") assert "\n" in output.message def test_output_serialization(self): output = CodeExecutorOutput(message="test output") data = output.model_dump() assert data["message"] == "test output" class TestDockerBackendModule: """Test docker_backend module functions.""" def test_cleanup_docker_resources(self): """Test cleanup_docker_resources calls ImageBuilder.reset_instance (covers line 23).""" with patch("vss_agents.tools.code_executor.docker_backend.ImageBuilder") as mock_builder: cleanup_docker_resources() mock_builder.reset_instance.assert_called_once() ================================================ FILE: agent/tests/unit_test/tools/test_embed_search.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for embed_search module.""" import json from unittest.mock import MagicMock from pydantic import ValidationError import pytest from vss_agents.tools.embed_search import EmbedSearchConfig from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.embed_search import EmbedSearchResultItem from vss_agents.tools.embed_search import QueryInput from vss_agents.tools.embed_search import _chat_request_input_converter class TestChatRequestInputConverter: """Test _chat_request_input_converter function.""" def test_valid_json_params(self): mock_message = MagicMock() mock_message.content = '{"params": {"query": "find cars"}, "source_type": "video_file"}' mock_request = MagicMock() mock_request.messages = [mock_message] result = _chat_request_input_converter(mock_request) assert result.params["query"] == "find cars" assert result.source_type == "video_file" def test_valid_json_prompts(self): mock_message = MagicMock() mock_message.content = '{"prompts": {"system": "analyze video"}, "source_type": "rtsp"}' mock_request = MagicMock() mock_request.messages = [mock_message] result = _chat_request_input_converter(mock_request) assert result.prompts["system"] == "analyze video" assert result.source_type == "rtsp" def test_invalid_json_uses_content_as_query(self): mock_message = MagicMock() mock_message.content = "plain text query" mock_request = MagicMock() mock_request.messages = [mock_message] result = _chat_request_input_converter(mock_request) assert result.params["query"] == "plain text query" assert result.source_type == "video_file" # fallback when not in Query format def test_json_without_params_or_prompts(self): mock_message = MagicMock() mock_message.content = '{"other_field": "value"}' mock_request = MagicMock() mock_request.messages = [mock_message] result = _chat_request_input_converter(mock_request) assert result.params["query"] == '{"other_field": "value"}' assert result.source_type == "video_file" # fallback when not in Query format class TestEmbedSearchConfigValidation: """Test EmbedSearchConfig validation.""" def test_missing_cosmos_endpoint_raises(self): with pytest.raises(ValidationError): EmbedSearchConfig( es_endpoint="http://localhost:9200", vst_external_url="http://localhost:8081", ) def test_missing_es_endpoint_raises(self): with pytest.raises(ValidationError): EmbedSearchConfig( cosmos_embed_endpoint="http://localhost:8080", vst_external_url="http://localhost:8081", ) def test_missing_vst_base_url_raises(self): with pytest.raises(ValidationError): EmbedSearchConfig( cosmos_embed_endpoint="http://localhost:8080", es_endpoint="http://localhost:9200", ) class TestQueryInputValidation: """Test QueryInput edge cases.""" def test_empty_embeddings_list(self): qi = QueryInput(embeddings=[], source_type="video_file") assert qi.embeddings == [] def test_embeddings_with_nested_dict(self): qi = QueryInput( embeddings=[{"vector": [0.1, 0.2], "info": {"model": "test"}}], source_type="rtsp", ) assert len(qi.embeddings) == 1 assert qi.embeddings[0]["vector"] == [0.1, 0.2] class TestEmbedSearchResultItem: """Test EmbedSearchResultItem model.""" def test_defaults(self): item = EmbedSearchResultItem() assert item.video_name == "" assert item.description == "" assert item.start_time == "" assert item.end_time == "" assert item.sensor_id == "" assert item.screenshot_url == "" assert item.similarity_score == 0.0 def test_with_values(self): item = EmbedSearchResultItem( video_name="video1.mp4", description="A parking lot video", start_time="2025-01-15T10:00:00Z", end_time="2025-01-15T10:01:00Z", sensor_id="21908c9a-bd40-4941-8a2e-79bc0880fb5a", screenshot_url="http://example.com/screenshot.jpg", similarity_score=0.95, ) assert item.video_name == "video1.mp4" assert item.description == "A parking lot video" assert item.start_time == "2025-01-15T10:00:00Z" assert item.end_time == "2025-01-15T10:01:00Z" assert item.sensor_id == "21908c9a-bd40-4941-8a2e-79bc0880fb5a" assert item.screenshot_url == "http://example.com/screenshot.jpg" assert item.similarity_score == 0.95 def test_serialization(self): item = EmbedSearchResultItem( video_name="test.mp4", similarity_score=0.85, ) json_str = item.model_dump_json() parsed = json.loads(json_str) assert parsed["video_name"] == "test.mp4" assert parsed["similarity_score"] == 0.85 class TestEmbedSearchOutput: """Test EmbedSearchOutput model.""" def test_defaults(self): output = EmbedSearchOutput() assert output.query_embedding == [] assert output.results == [] def test_with_results(self): item1 = EmbedSearchResultItem(video_name="video1.mp4", similarity_score=0.9) item2 = EmbedSearchResultItem(video_name="video2.mp4", similarity_score=0.8) output = EmbedSearchOutput( query_embedding=[0.1, 0.2, 0.3], results=[item1, item2], ) assert len(output.query_embedding) == 3 assert len(output.results) == 2 assert output.results[0].video_name == "video1.mp4" assert output.results[1].video_name == "video2.mp4" def test_serialization(self): item = EmbedSearchResultItem(video_name="test.mp4", similarity_score=0.9) output = EmbedSearchOutput( query_embedding=[0.1, 0.2], results=[item], ) json_str = output.model_dump_json() parsed = json.loads(json_str) assert parsed["query_embedding"] == [0.1, 0.2] assert len(parsed["results"]) == 1 assert parsed["results"][0]["video_name"] == "test.mp4" ================================================ FILE: agent/tests/unit_test/tools/test_embed_search_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for embed_search module to improve coverage.""" import json from vss_agents.tools.embed_search import BASE_2025 from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.embed_search import _sanitize_for_logging from vss_agents.tools.embed_search import _str_input_converter class TestSanitizeForLogging: """Test _sanitize_for_logging function.""" def test_dict_with_vector_field(self): obj = {"vector": [0.1, 0.2, 0.3], "other": "data"} result = _sanitize_for_logging(obj) assert result["vector"] == "" assert result["other"] == "data" def test_dict_with_empty_vector(self): obj = {"vector": []} result = _sanitize_for_logging(obj) assert result["vector"] == "" def test_dict_with_query_vector(self): obj = {"query_vector": [0.1, 0.2]} result = _sanitize_for_logging(obj) assert result["query_vector"] == "" def test_dict_with_embeddings_list(self): obj = {"embeddings": [[0.1], [0.2], [0.3]]} result = _sanitize_for_logging(obj) assert result["embeddings"] == "" def test_nested_dict(self): obj = {"outer": {"vector": [0.1, 0.2]}} result = _sanitize_for_logging(obj) assert result["outer"]["vector"] == "" def test_list_of_dicts(self): obj = [{"vector": [0.1]}, {"name": "test"}] result = _sanitize_for_logging(obj) assert result[0]["vector"] == "" assert result[1]["name"] == "test" def test_plain_value(self): assert _sanitize_for_logging("hello") == "hello" assert _sanitize_for_logging(42) == 42 assert _sanitize_for_logging(None) is None def test_vector_non_list(self): obj = {"vector": "not_a_list"} result = _sanitize_for_logging(obj) assert result["vector"] == "" class TestStrInputConverterEdgeCases: """Test _str_input_converter edge cases.""" def test_json_with_both_params_and_prompts_and_source_type(self): input_str = '{"params": {"query": "test"}, "prompts": {"sys": "hello"}, "source_type": "video_file"}' result = _str_input_converter(input_str) assert result.params["query"] == "test" assert result.prompts["sys"] == "hello" def test_json_missing_source_type_falls_back(self): """When source_type is missing, QueryInput validation fails and fallback is used.""" input_str = '{"params": {"query": "test"}, "prompts": {"sys": "hello"}}' result = _str_input_converter(input_str) # Falls back to default with source_type="video_file" assert result.source_type == "video_file" class TestBase2025Constant: """Test BASE_2025 constant.""" def test_base_2025_is_utc(self): from datetime import UTC assert BASE_2025.tzinfo is UTC def test_base_2025_is_jan_1(self): assert BASE_2025.year == 2025 assert BASE_2025.month == 1 assert BASE_2025.day == 1 class TestEmbedSearchOutputEdgeCases: """Test EmbedSearchOutput edge cases.""" def test_with_query_embedding(self): output = EmbedSearchOutput(query_embedding=[0.1, 0.2, 0.3], results=[]) assert len(output.query_embedding) == 3 def test_empty_results_serialization(self): output = EmbedSearchOutput() json_str = output.model_dump_json() parsed = json.loads(json_str) assert parsed["results"] == [] assert parsed["query_embedding"] == [] ================================================ FILE: agent/tests/unit_test/tools/test_embed_search_edge_cases.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Edge case tests for embed_search to cover remaining lines.""" import json from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.tools.embed_search import EmbedSearchConfig from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.embed_search import QueryInput from vss_agents.tools.embed_search import embed_search def _make_es_response(hits): response = MagicMock() response.body = {"hits": {"hits": hits, "total": {"value": len(hits)}}} response.__getitem__ = lambda self, key: self.body[key] return response class TestEmbedSearchEdgeCases: """Cover remaining edge case lines in embed_search.""" @pytest.fixture def config(self): return EmbedSearchConfig( cosmos_embed_endpoint="http://localhost:8080", es_endpoint="http://localhost:9200", vst_external_url="http://vst-external:8080", vst_internal_url="http://vst-internal:8080", ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.fixture def mock_es_client(self): client = AsyncMock() client.indices.exists.return_value = True return client @pytest.fixture def mock_embed_client(self): client = AsyncMock() client.get_text_embedding.return_value = [0.1, 0.2, 0.3] return client async def _get_inner_fn(self, config, mock_builder, mock_es_client, mock_embed_client): with patch("vss_agents.tools.embed_search.AsyncElasticsearch", return_value=mock_es_client): with patch("vss_agents.tools.embed_search.CosmosEmbedClient", return_value=mock_embed_client): gen = embed_search.__wrapped__(config, mock_builder) fi = await gen.__anext__() return fi.single_fn @pytest.mark.asyncio async def test_top_k_limits_results(self, config, mock_builder, mock_es_client, mock_embed_client): """Test that top_k limits the number of results.""" hits = [] for i in range(10): hits.append( { "_id": f"hit{i}", "_score": 0.95 - i * 0.01, "_source": { "timestamp": "2025-01-01T00:00:00Z", "end": "2025-01-01T01:00:00Z", "sensor": {"id": f"s{i}", "info": {"url": f"v{i}.mp4"}}, "llm": { "queries": [ { "id": f"q{i}", "response": json.dumps({"video_name": f"v{i}.mp4"}), "params": {}, "prompts": {}, "embeddings": [], } ], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, }, } ) mock_es_client.search.return_value = _make_es_response(hits) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test", "top_k": "3"}, source_type="video_file") result = await inner_fn(query_input) # Should only have 3 results due to top_k assert isinstance(result, EmbedSearchOutput) assert len(result.results) == 3 @pytest.mark.asyncio async def test_empty_response_field(self, config, mock_builder, mock_es_client, mock_embed_client): """Test when response field is empty string.""" source = { "timestamp": "2025-01-01T00:00:00Z", "sensor": {"id": "s1", "info": {"url": "v.mp4"}}, "llm": { "queries": [{"id": "q1", "response": ""}], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([{"_id": "h1", "_score": 0.95, "_source": source}]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_no_sensor_description(self, config, mock_builder, mock_es_client, mock_embed_client): """Test when no sensor description is available.""" source = { "timestamp": "2025-01-01T00:00:00Z", "end": "", "sensor": {"id": "s1", "info": {"url": "v.mp4"}, "description": ""}, "llm": { "queries": [ { "id": "q1", "response": json.dumps({"video_name": "v.mp4"}), "params": {}, "prompts": {}, "embeddings": [], } ], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([{"_id": "h1", "_score": 0.95, "_source": source}]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_response_data_not_dict(self, config, mock_builder, mock_es_client, mock_embed_client): """Test when response data is not a dict.""" source = { "timestamp": "2025-01-01T00:00:00Z", "sensor": {"id": "s1", "info": {"url": "v.mp4"}}, "llm": { "queries": [{"id": "q1", "response": '"just a string"', "params": {}, "prompts": {}, "embeddings": []}], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([{"_id": "h1", "_score": 0.95, "_source": source}]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_with_filters_and_timestamps(self, config, mock_builder, mock_es_client, mock_embed_client): """Test with multiple filters applied.""" mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput( params={ "query": "test", "video_sources": '["v1.mp4"]', "description": "parking", "timestamp_start": "2025-01-15T10:00:00Z", "timestamp_end": "2025-01-15T11:00:00Z", "top_k": "5", "min_cosine_similarity": "0.3", }, source_type="video_file", ) result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_timestamp_without_tz(self, config, mock_builder, mock_es_client, mock_embed_client): """Test timestamps without timezone info.""" mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput( params={ "query": "test", "timestamp_start": "2025-01-15T10:00:00", "timestamp_end": "2025-01-15T11:00:00", }, source_type="video_file", ) result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) ================================================ FILE: agent/tests/unit_test/tools/test_embed_search_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for embed_search inner function via generator invocation.""" import json from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.tools.embed_search import EmbedSearchConfig from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.embed_search import QueryInput from vss_agents.tools.embed_search import embed_search def _make_es_hit(source, score=0.9): """Create a mock ES hit.""" return {"_id": "hit1", "_score": score, "_source": source} def _make_es_response(hits): """Create a mock ES response.""" response = MagicMock() response.body = {"hits": {"hits": hits, "total": {"value": len(hits)}}} response.__getitem__ = lambda self, key: self.body[key] return response class TestEmbedSearchInner: """Test the inner _embed_search function.""" @pytest.fixture def config(self): return EmbedSearchConfig( cosmos_embed_endpoint="http://localhost:8080", es_endpoint="http://localhost:9200", vst_external_url="http://vst-external:8080", default_max_results=100, ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.fixture def mock_es_client(self): client = AsyncMock() client.indices.exists.return_value = True return client @pytest.fixture def mock_embed_client(self): client = AsyncMock() client.get_text_embedding.return_value = [0.1, 0.2, 0.3] client.get_image_embedding.return_value = [0.4, 0.5, 0.6] client.get_video_embedding.return_value = [0.7, 0.8, 0.9] return client async def _get_inner_fn(self, config, mock_builder, mock_es_client, mock_embed_client): with patch("vss_agents.tools.embed_search.AsyncElasticsearch", return_value=mock_es_client): with patch("vss_agents.tools.embed_search.CosmosEmbedClient", return_value=mock_embed_client): gen = embed_search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() return function_info.single_fn @pytest.mark.asyncio async def test_text_query(self, config, mock_builder, mock_es_client, mock_embed_client): source = { "timestamp": "2025-01-15T10:00:00Z", "end": "2025-01-15T10:30:00Z", "sensor": { "id": "stream1", "type": "camera", "description": "Front cam", "info": {"url": "video1.mp4"}, }, "llm": { "queries": [ { "id": "q1", "response": json.dumps({"video_name": "v1.mp4"}), "params": {}, "prompts": {}, "embeddings": [], } ], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source, 0.95)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "find cars"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) assert len(result.results) > 0 @pytest.mark.asyncio async def test_image_url_query(self, config, mock_builder, mock_es_client, mock_embed_client): source = { "timestamp": "2025-01-01T00:00:00Z", "end": "2025-01-01T01:00:00Z", "sensor": {"id": "s1", "info": {"url": "v.mp4"}}, "llm": { "queries": [{"id": "q1", "response": "{}"}], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"image_url": "http://example.com/img.jpg"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_video_url_query(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"video_url": "http://example.com/video.mp4"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) assert len(result.results) == 0 @pytest.mark.asyncio async def test_precomputed_embeddings(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(embeddings=[{"vector": [0.1, 0.2, 0.3]}], source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_no_query_raises(self, config, mock_builder, mock_es_client, mock_embed_client): inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={}, source_type="video_file") with pytest.raises(ValueError, match="Either query"): await inner_fn(query_input) @pytest.mark.asyncio async def test_with_video_sources_filter(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput( params={"query": "test", "video_sources": '["video1.mp4", "video2.mp4"]'}, source_type="video_file" ) result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_with_comma_separated_video_sources(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput( params={"query": "test", "video_sources": "video1.mp4, video2.mp4"}, source_type="video_file" ) result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_with_description_filter(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test", "description": "parking lot"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_with_timestamp_filters(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput( params={ "query": "test", "timestamp_start": "2025-01-15T10:00:00Z", "timestamp_end": "2025-01-15T11:00:00Z", }, source_type="video_file", ) result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_with_top_k(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test", "top_k": "5"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_with_min_cosine_similarity(self, config, mock_builder, mock_es_client, mock_embed_client): source = { "timestamp": "2025-01-01T00:00:00Z", "end": "2025-01-01T01:00:00Z", "sensor": {"id": "s1", "info": {"url": "v.mp4"}}, "llm": { "queries": [{"id": "q1", "response": "{}"}], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source, 0.3)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test", "min_cosine_similarity": "0.5"}, source_type="video_file") result = await inner_fn(query_input) # Score 0.3 -> cosine = 2*0.3-1 = -0.4 < 0.5 -> filtered out assert len(result.results) == 0 @pytest.mark.asyncio async def test_es_index_not_found(self, config, mock_builder, mock_es_client, mock_embed_client): from elasticsearch import NotFoundError as ESNotFoundError # Create proper ESNotFoundError with ApiResponseMeta mock_meta = MagicMock() mock_meta.status = 404 mock_es_client.search.side_effect = ESNotFoundError(message="index not found", meta=mock_meta, body={}) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") with pytest.raises(ValueError, match="does not exist"): await inner_fn(query_input) @pytest.mark.asyncio async def test_hit_without_llm_skipped(self, config, mock_builder, mock_es_client, mock_embed_client): source = { "timestamp": "2025-01-01T00:00:00Z", "end": "2025-01-01T01:00:00Z", "sensor": {"id": "s1", "info": {}}, # No "llm" field } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert len(result.results) == 0 @pytest.mark.asyncio async def test_hit_with_location_and_coordinate(self, config, mock_builder, mock_es_client, mock_embed_client): source = { "timestamp": "2025-01-01T00:00:00Z", "end": "2025-01-01T01:00:00Z", "sensor": { "id": "s1", "type": "camera", "description": "cam", "location": {"lat": 37.0, "lon": -122.0, "alt": 10.0}, "coordinate": {"x": 1.0, "y": 2.0, "z": 3.0}, "info": {"url": "video.mp4"}, }, "info": {"key": "value"}, "llm": { "queries": [ { "id": "q1", "response": json.dumps({"video_name": "v.mp4"}), "params": {}, "prompts": {}, "embeddings": [{"vector": [0.1], "info": {"m": "c"}}], } ], }, } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source, 0.95)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) assert len(result.results) == 1 assert result.results[0].video_name == "v.mp4" @pytest.mark.asyncio async def test_hit_with_no_queries(self, config, mock_builder, mock_es_client, mock_embed_client): source = { "timestamp": "2025-01-01T00:00:00Z", "sensor": {"id": "s1", "info": {}}, "llm": { "queries": [], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source, 0.95)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) assert len(result.results) == 1 @pytest.mark.asyncio async def test_invalid_timestamp_in_response(self, config, mock_builder, mock_es_client, mock_embed_client): source = { "timestamp": "invalid-timestamp", "end": "also-invalid", "sensor": {"id": "s1", "info": {"url": "v.mp4"}}, "llm": { "queries": [ { "id": "q1", "response": json.dumps({"video_name": "v.mp4"}), "params": {}, "prompts": {}, "embeddings": [], } ], }, } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source, 0.95)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_timestamp_start_only(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput( params={"query": "test", "timestamp_start": "2025-01-15T10:00:00Z"}, source_type="video_file" ) result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_invalid_timestamp_in_params(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput( params={"query": "test", "timestamp_start": "not-a-date", "timestamp_end": "also-invalid"}, source_type="video_file", ) result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_with_vst_internal_url(self, mock_builder, mock_es_client, mock_embed_client): config = EmbedSearchConfig( cosmos_embed_endpoint="http://localhost:8080", es_endpoint="http://localhost:9200", vst_external_url="http://vst-external:8080", vst_internal_url="http://vst-internal:8080", ) mock_es_client.search.return_value = _make_es_response([]) with patch("vss_agents.tools.embed_search.AsyncElasticsearch", return_value=mock_es_client): with patch("vss_agents.tools.embed_search.CosmosEmbedClient", return_value=mock_embed_client): gen = embed_search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_single_video_source_not_list(self, config, mock_builder, mock_es_client, mock_embed_client): mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test", "video_sources": '"single_video"'}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_queries_data_not_list(self, config, mock_builder, mock_es_client, mock_embed_client): source = { "timestamp": "2025-01-01T00:00:00Z", "sensor": {"id": "s1", "info": {}}, "llm": { "queries": "not_a_list", "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source, 0.95)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_response_not_json(self, config, mock_builder, mock_es_client, mock_embed_client): """Test when stored query response is not valid JSON.""" source = { "timestamp": "2025-01-01T00:00:00Z", "sensor": {"id": "s1", "info": {"url": "v.mp4"}}, "llm": { "queries": [{"id": "q1", "response": "not json"}], "visionEmbeddings": [{"vector": [0.1, 0.2]}], }, } mock_es_client.search.return_value = _make_es_response([_make_es_hit(source, 0.95)]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) assert len(result.results) == 1 @pytest.mark.asyncio async def test_rtsp_source_type(self, config, mock_builder, mock_es_client, mock_embed_client): """Test with rtsp source_type uses different search indices.""" mock_es_client.search.return_value = _make_es_response([]) inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="rtsp") result = await inner_fn(query_input) assert isinstance(result, EmbedSearchOutput) @pytest.mark.asyncio async def test_video_file_index_not_exists(self, config, mock_builder, mock_es_client, mock_embed_client): """Test video_file source_type raises when index doesn't exist.""" mock_es_client.indices.exists.return_value = False inner_fn = await self._get_inner_fn(config, mock_builder, mock_es_client, mock_embed_client) query_input = QueryInput(params={"query": "test"}, source_type="video_file") with pytest.raises(ValueError, match="does not exist"): await inner_fn(query_input) ================================================ FILE: agent/tests/unit_test/tools/test_evaluation_compressor.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for evaluation_compressor module.""" from pydantic import ValidationError import pytest from vss_agents.tools.evaluation_compressor import EvaluationCompressorConfig from vss_agents.tools.evaluation_compressor import EvaluationCompressorInput from vss_agents.tools.evaluation_compressor import remove_caption_details from vss_agents.tools.evaluation_compressor import split_text_by_sections class TestEvaluationCompressorConfig: """Test EvaluationCompressorConfig model.""" def test_required_fields(self): config = EvaluationCompressorConfig( llm_name="openai_llm", token_limit=4000, ) assert config.llm_name == "openai_llm" assert config.token_limit == 4000 assert config.remove_caption_details is True def test_custom_remove_caption_details(self): config = EvaluationCompressorConfig( llm_name="openai_llm", token_limit=8000, remove_caption_details=False, ) assert config.remove_caption_details is False def test_missing_llm_name_fails(self): with pytest.raises(ValidationError): EvaluationCompressorConfig(token_limit=4000) def test_missing_token_limit_fails(self): with pytest.raises(ValidationError): EvaluationCompressorConfig(llm_name="openai_llm") class TestEvaluationCompressorInput: """Test EvaluationCompressorInput model.""" def test_basic_input(self): input_data = EvaluationCompressorInput(input_text="This is some text to compress.") assert input_data.input_text == "This is some text to compress." def test_empty_string(self): input_data = EvaluationCompressorInput(input_text="") assert input_data.input_text == "" def test_long_text(self): long_text = "A" * 10000 input_data = EvaluationCompressorInput(input_text=long_text) assert len(input_data.input_text) == 10000 class TestRemoveCaptionDetails: """Test remove_caption_details function.""" def test_removes_timestamp_lines(self): text = """[0.0] Person walking [1.5] Vehicle passing Regular text here""" result = remove_caption_details(text) assert "[0.0]" not in result assert "[1.5]" not in result assert "Regular text here" in result def test_preserves_non_timestamp_lines(self): text = """This is regular text. Another line without timestamps. Final line.""" result = remove_caption_details(text) assert "This is regular text." in result assert "Another line without timestamps." in result assert "Final line." in result def test_empty_input(self): result = remove_caption_details("") assert result == "" def test_mixed_content(self): text = """Introduction paragraph. [0.0] Caption 1 [1.0] Caption 2 Middle paragraph. [2.0] Caption 3 Conclusion paragraph.""" result = remove_caption_details(text) assert "Introduction paragraph." in result assert "Middle paragraph." in result assert "Conclusion paragraph." in result assert "[0.0]" not in result assert "[1.0]" not in result assert "[2.0]" not in result def test_timestamp_with_spaces(self): text = """ [0.5] Indented caption [1.0] Normal caption""" result = remove_caption_details(text) assert "[0.5]" not in result assert "[1.0]" not in result class TestSplitTextBySections: """Test split_text_by_sections function.""" def test_single_section(self): text = "Paragraph 1" sections = split_text_by_sections(text, 1) assert len(sections) == 1 assert sections[0] == "Paragraph 1" def test_two_sections(self): text = """Paragraph 1 Paragraph 2""" sections = split_text_by_sections(text, 2) assert len(sections) == 2 assert "Paragraph 1" in sections[0] assert "Paragraph 2" in sections[1] def test_more_sections_than_paragraphs(self): text = "Single paragraph" sections = split_text_by_sections(text, 3) # Should return at least as many sections as paragraphs assert len(sections) >= 1 def test_equal_sections(self): text = """P1 P2 P3 P4""" sections = split_text_by_sections(text, 2) assert len(sections) == 2 def test_invalid_num_sections(self): with pytest.raises(ValueError): split_text_by_sections("test", 0) def test_negative_num_sections(self): with pytest.raises(ValueError): split_text_by_sections("test", -1) def test_many_paragraphs(self): paragraphs = [f"Paragraph {i}" for i in range(10)] text = "\n\n".join(paragraphs) sections = split_text_by_sections(text, 3) assert len(sections) == 3 ================================================ FILE: agent/tests/unit_test/tools/test_fov_counts.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for fov_counts_with_chart module.""" from vss_agents.tools.fov_counts_with_chart import FOVCountsWithChartConfig from vss_agents.tools.fov_counts_with_chart import FOVCountsWithChartInput from vss_agents.tools.fov_counts_with_chart import FOVCountsWithChartOutput class TestFOVCountsWithChartConfig: """Test FOVCountsWithChartConfig model.""" def test_config_creation(self): config = FOVCountsWithChartConfig( get_fov_histogram_tool="get_fov_histogram", chart_generator_tool="chart_generator", ) assert config.get_fov_histogram_tool == "get_fov_histogram" assert config.chart_generator_tool == "chart_generator" assert config.chart_base_url == "http://localhost:38000/reports/" def test_config_custom_base_url(self): config = FOVCountsWithChartConfig( get_fov_histogram_tool="get_fov_histogram", chart_generator_tool="chart_generator", chart_base_url="http://example.com/charts/", ) assert config.chart_base_url == "http://example.com/charts/" class TestFOVCountsWithChartInput: """Test FOVCountsWithChartInput model.""" def test_input_minimal(self): input_data = FOVCountsWithChartInput( sensor_id="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", ) assert input_data.sensor_id == "sensor-001" assert input_data.object_type is None assert input_data.bucket_count == 10 def test_input_full(self): input_data = FOVCountsWithChartInput( sensor_id="sensor-002", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", object_type="Person", bucket_count=20, ) assert input_data.object_type == "Person" assert input_data.bucket_count == 20 def test_input_various_object_types(self): for obj_type in ["Person", "Vehicle", "Animal"]: input_data = FOVCountsWithChartInput( sensor_id="sensor", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", object_type=obj_type, ) assert input_data.object_type == obj_type class TestFOVCountsWithChartOutput: """Test FOVCountsWithChartOutput model.""" def test_output_creation(self): output = FOVCountsWithChartOutput( summary="Found 100 objects", latest_count=15, average_count=12.5, raw_histogram={"histogram": []}, ) assert output.summary == "Found 100 objects" assert output.latest_count == 15 assert output.average_count == 12.5 assert output.chart_url is None def test_output_with_chart_url(self): output = FOVCountsWithChartOutput( summary="Objects counted", latest_count=10, average_count=8.0, chart_url="http://localhost:38000/reports/chart.png", raw_histogram={"histogram": [{"count": 10}]}, ) assert output.chart_url == "http://localhost:38000/reports/chart.png" def test_output_zero_counts(self): output = FOVCountsWithChartOutput( summary="No objects found", latest_count=0, average_count=0.0, raw_histogram={}, ) assert output.latest_count == 0 assert output.average_count == 0.0 def test_output_serialization(self): output = FOVCountsWithChartOutput( summary="Test", latest_count=5, average_count=5.0, raw_histogram={"test": True}, ) data = output.model_dump() assert "summary" in data assert "latest_count" in data assert "average_count" in data assert "chart_url" in data assert "raw_histogram" in data ================================================ FILE: agent/tests/unit_test/tools/test_geolocation.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for geolocation module.""" from vss_agents.tools.geolocation import GeolocationConfig from vss_agents.tools.geolocation import GeolocationInput from vss_agents.tools.geolocation import GeolocationOutput class TestGeolocationConfig: """Test GeolocationConfig model.""" def test_config_defaults(self): config = GeolocationConfig() assert config.timeout == 10 def test_config_custom_timeout(self): config = GeolocationConfig(timeout=30) assert config.timeout == 30 class TestGeolocationInput: """Test GeolocationInput model.""" def test_input_creation(self): input_data = GeolocationInput(latitude=37.7749, longitude=-122.4194) assert input_data.latitude == 37.7749 assert input_data.longitude == -122.4194 def test_input_zero_coordinates(self): input_data = GeolocationInput(latitude=0.0, longitude=0.0) assert input_data.latitude == 0.0 assert input_data.longitude == 0.0 def test_input_negative_coordinates(self): input_data = GeolocationInput(latitude=-33.8688, longitude=151.2093) assert input_data.latitude == -33.8688 assert input_data.longitude == 151.2093 def test_input_extreme_latitude(self): input_data = GeolocationInput(latitude=90.0, longitude=0.0) assert input_data.latitude == 90.0 def test_input_extreme_longitude(self): input_data = GeolocationInput(latitude=0.0, longitude=180.0) assert input_data.longitude == 180.0 class TestGeolocationOutput: """Test GeolocationOutput model.""" def test_output_defaults(self): output = GeolocationOutput() assert output.type is None assert output.city is None assert output.county is None assert output.state is None assert output.country is None assert output.road is None assert output.speed_limit is None assert output.full_address is None assert output.category is None assert output.subtype_within_category is None def test_output_full_data(self): output = GeolocationOutput( type="street", city="San Francisco", county="San Francisco County", state="California", country="United States", road="Market Street", speed_limit="25 mph", full_address="123 Market Street, San Francisco, CA 94102", category="highway", subtype_within_category="residential", ) assert output.type == "street" assert output.city == "San Francisco" assert output.state == "California" assert output.country == "United States" assert output.road == "Market Street" assert output.speed_limit == "25 mph" def test_output_partial_data(self): output = GeolocationOutput( city="New York", country="United States", ) assert output.city == "New York" assert output.country == "United States" assert output.state is None def test_output_serialization(self): output = GeolocationOutput(city="Tokyo", country="Japan") data = output.model_dump() assert data["city"] == "Tokyo" assert data["country"] == "Japan" assert data["state"] is None ================================================ FILE: agent/tests/unit_test/tools/test_incidents.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for incidents module.""" from vss_agents.tools.incidents import DuckDBIncidentsManager from vss_agents.tools.incidents import VARetrievalConfig from vss_agents.tools.incidents import VARetrievalInput class TestVARetrievalConfig: """Test VARetrievalConfig model.""" def test_defaults(self): config = VARetrievalConfig() assert config.minio_url == "http://localhost:9000" assert config.access_key == "minioadmin" assert config.secret_key == "minioadmin" # pragma: allowlist secret assert config.bucket_name == "incidents-bucket" assert config.prefix == "" assert config.db_path == ":memory:" assert config.file_extensions == [".json", ".ndjson"] assert config.auto_refresh is False def test_custom_values(self): config = VARetrievalConfig( minio_url="http://custom-minio:9000", access_key="custom-access", secret_key="custom-secret", # pragma: allowlist secret bucket_name="custom-bucket", prefix="incidents/", db_path="/tmp/incidents.duckdb", file_extensions=[".json"], auto_refresh=True, ) assert config.minio_url == "http://custom-minio:9000" assert config.access_key == "custom-access" assert config.secret_key == "custom-secret" # pragma: allowlist secret assert config.bucket_name == "custom-bucket" assert config.prefix == "incidents/" assert config.db_path == "/tmp/incidents.duckdb" assert config.file_extensions == [".json"] assert config.auto_refresh is True class TestVARetrievalInput: """Test VARetrievalInput model.""" def test_defaults(self): input_data = VARetrievalInput() assert input_data.action is None assert input_data.sql_query is None assert input_data.id is None assert input_data.start_time is None assert input_data.end_time is None assert input_data.source is None assert input_data.source_type is None assert input_data.max_count == 10 assert input_data.includes is None def test_sql_query_action(self): input_data = VARetrievalInput(action="query", sql_query="SELECT * FROM incidents LIMIT 10") assert input_data.action == "query" assert input_data.sql_query == "SELECT * FROM incidents LIMIT 10" def test_get_schema_action(self): input_data = VARetrievalInput(action="get_schema") assert input_data.action == "get_schema" def test_single_incident_retrieval(self): input_data = VARetrievalInput(id="incident-123") assert input_data.id == "incident-123" def test_time_range_query(self): input_data = VARetrievalInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", source="sensor-001", source_type="sensor", max_count=50, ) assert input_data.start_time == "2025-01-01T00:00:00.000Z" assert input_data.end_time == "2025-01-01T23:59:59.000Z" assert input_data.source == "sensor-001" assert input_data.source_type == "sensor" assert input_data.max_count == 50 def test_place_source_type(self): input_data = VARetrievalInput( source="Main Street", source_type="place", ) assert input_data.source == "Main Street" assert input_data.source_type == "place" def test_with_includes(self): input_data = VARetrievalInput(includes=["objectIds", "info", "place"]) assert input_data.includes == ["objectIds", "info", "place"] class TestDuckDBIncidentsManagerNormalizeTimestamp: """Test DuckDBIncidentsManager.normalize_timestamp static method.""" def test_none_timestamp(self): result = DuckDBIncidentsManager.normalize_timestamp(None) assert result is None def test_z_suffix_conversion(self): result = DuckDBIncidentsManager.normalize_timestamp("2025-01-01T12:00:00Z") assert "+00:00" in result or "Z" in result # DuckDB format def test_timestamp_with_offset(self): result = DuckDBIncidentsManager.normalize_timestamp("2025-01-01T12:00:00+05:00") assert result is not None def test_timestamp_with_milliseconds(self): result = DuckDBIncidentsManager.normalize_timestamp("2025-01-01T12:00:00.123Z") assert result is not None def test_timestamp_with_microseconds(self): result = DuckDBIncidentsManager.normalize_timestamp("2025-01-01T12:00:00.123456Z") assert result is not None class TestDuckDBIncidentsManagerInit: """Test DuckDBIncidentsManager initialization.""" def test_init(self): config = VARetrievalConfig() manager = DuckDBIncidentsManager(config) assert manager.config == config assert manager._initialized is False assert manager.s3_client is None assert manager.conn is None def test_class_level_instances(self): """Test that class-level storage is initialized.""" assert isinstance(DuckDBIncidentsManager._instances, dict) assert isinstance(DuckDBIncidentsManager._locks, dict) ================================================ FILE: agent/tests/unit_test/tools/test_lvs_video_understanding.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for lvs_video_understanding module.""" from pydantic import ValidationError import pytest from vss_agents.tools.lvs_video_understanding import LVSVideoUnderstandingConfig from vss_agents.tools.lvs_video_understanding import LVSVideoUnderstandingInput class TestLVSVideoUnderstandingConfig: """Test LVSVideoUnderstandingConfig model.""" def test_with_required_fields(self): config = LVSVideoUnderstandingConfig( lvs_backend_url="http://localhost:38111", hitl_scenario_template="Scenario: {scenario}", hitl_events_template="Events: {events}", hitl_objects_template="Objects: {objects}", ) assert config.lvs_backend_url == "http://localhost:38111" assert config.hitl_scenario_template == "Scenario: {scenario}" assert config.hitl_events_template == "Events: {events}" assert config.hitl_objects_template == "Objects: {objects}" # Check defaults assert config.conn_timeout_ms == 5000 assert config.read_timeout_ms == 600000 assert config.model == "gpt-4o" assert config.video_url_tool == "vst_video_url" def test_custom_timeouts(self): config = LVSVideoUnderstandingConfig( lvs_backend_url="http://localhost:38111", hitl_scenario_template="Scenario template", hitl_events_template="Events template", hitl_objects_template="Objects template", conn_timeout_ms=10000, read_timeout_ms=1200000, ) assert config.conn_timeout_ms == 10000 assert config.read_timeout_ms == 1200000 def test_custom_model(self): config = LVSVideoUnderstandingConfig( lvs_backend_url="http://localhost:38111", hitl_scenario_template="Scenario template", hitl_events_template="Events template", hitl_objects_template="Objects template", model="custom-model", ) assert config.model == "custom-model" def test_custom_video_url_tool(self): config = LVSVideoUnderstandingConfig( lvs_backend_url="http://localhost:38111", hitl_scenario_template="Scenario template", hitl_events_template="Events template", hitl_objects_template="Objects template", video_url_tool="custom_video_tool", ) assert config.video_url_tool == "custom_video_tool" def test_missing_lvs_backend_url_fails(self): with pytest.raises(ValidationError): LVSVideoUnderstandingConfig( hitl_scenario_template="Scenario template", hitl_events_template="Events template", hitl_objects_template="Objects template", ) def test_missing_hitl_template_fails(self): with pytest.raises(ValidationError): LVSVideoUnderstandingConfig( lvs_backend_url="http://localhost:38111", hitl_events_template="Events template", hitl_objects_template="Objects template", ) class TestLVSVideoUnderstandingInput: """Test LVSVideoUnderstandingInput model.""" def test_basic_input(self): input_data = LVSVideoUnderstandingInput( sensor_id="sensor-001", ) assert input_data.sensor_id == "sensor-001" def test_missing_sensor_id_fails(self): with pytest.raises(ValidationError): LVSVideoUnderstandingInput() def test_empty_sensor_id_fails(self): with pytest.raises(ValidationError): LVSVideoUnderstandingInput( sensor_id="", ) ================================================ FILE: agent/tests/unit_test/tools/test_multi_incident_formatter.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/tools/multi_incident_formatter.py.""" from datetime import datetime from datetime import timedelta from vss_agents.tools.multi_incident_formatter import IncidentData from vss_agents.tools.multi_incident_formatter import MultiIncidentFormatterInput from vss_agents.tools.multi_incident_formatter import MultiIncidentFormatterOutput from vss_agents.tools.multi_incident_formatter import _determine_optimal_bin_size from vss_agents.tools.multi_incident_formatter import _normalize_timestamp class TestNormalizeTimestamp: """Tests for _normalize_timestamp function.""" def test_normalize_microseconds(self): """Test normalizing timestamp with microseconds.""" result = _normalize_timestamp("2025-11-17T15:16:38.273512Z") assert result == "2025-11-17T15:16:38.273Z" def test_normalize_already_correct(self): """Test timestamp that's already in correct format.""" result = _normalize_timestamp("2025-11-17T15:16:38.273Z") assert result == "2025-11-17T15:16:38.273Z" def test_normalize_short_milliseconds(self): """Test normalizing timestamp with less than 3 digits.""" result = _normalize_timestamp("2025-11-17T15:16:38.27Z") assert result == "2025-11-17T15:16:38.270Z" def test_normalize_no_fractional(self): """Test timestamp without fractional seconds.""" result = _normalize_timestamp("2025-11-17T15:16:38Z") # Should return as-is since there's no dot assert result == "2025-11-17T15:16:38Z" class TestIncidentData: """Tests for IncidentData model.""" def test_create_incident_data(self): """Test creating IncidentData.""" data = IncidentData( incident_id="inc-001", sensor_id="sensor-001", start_timestamp="2025-01-15T10:00:00.000Z", end_timestamp="2025-01-15T10:05:00.000Z", metadata={"category": "traffic"}, ) assert data.incident_id == "inc-001" assert data.sensor_id == "sensor-001" assert data.metadata["category"] == "traffic" def test_incident_data_default_metadata(self): """Test IncidentData with default metadata.""" data = IncidentData( incident_id="inc-001", sensor_id="sensor-001", start_timestamp="2025-01-15T10:00:00.000Z", end_timestamp="2025-01-15T10:05:00.000Z", ) assert data.metadata == {} class TestMultiIncidentFormatterInput: """Tests for MultiIncidentFormatterInput model.""" def test_create_input_basic(self): """Test creating basic input.""" inp = MultiIncidentFormatterInput( source="sensor-001", source_type="sensor", ) assert inp.source == "sensor-001" assert inp.source_type == "sensor" assert inp.max_result_size == 10000 def test_create_input_with_times(self): """Test creating input with time range.""" inp = MultiIncidentFormatterInput( source="San Jose", source_type="place", start_time="2025-01-15T10:00:00.000Z", end_time="2025-01-15T11:00:00.000Z", ) assert inp.start_time == "2025-01-15T10:00:00.000Z" assert inp.end_time == "2025-01-15T11:00:00.000Z" def test_create_input_timestamp_normalization(self): """Test that timestamps are normalized.""" inp = MultiIncidentFormatterInput( source="sensor-001", source_type="sensor", start_time="2025-01-15T10:00:00.123456Z", end_time="2025-01-15T11:00:00.789012Z", ) assert inp.start_time == "2025-01-15T10:00:00.123Z" assert inp.end_time == "2025-01-15T11:00:00.789Z" class TestMultiIncidentFormatterOutput: """Tests for MultiIncidentFormatterOutput model.""" def test_create_output(self): """Test creating output.""" output = MultiIncidentFormatterOutput( formatted_incidents="...", total_incidents=10, chart_html="", ) assert output.total_incidents == 10 assert output.chart_html is not None def test_create_output_no_chart(self): """Test creating output without chart.""" output = MultiIncidentFormatterOutput( formatted_incidents="...", total_incidents=5, ) assert output.chart_html is None class TestDetermineOptimalBinSize: """Tests for _determine_optimal_bin_size function.""" def test_determine_bin_size_empty(self): """Test with empty incidents.""" result = _determine_optimal_bin_size([]) assert result is None def test_determine_bin_size_single_incident(self): """Test with single incident.""" incidents = [ IncidentData( incident_id="inc-001", sensor_id="sensor-001", start_timestamp="2025-01-15T10:00:00.000Z", end_timestamp="2025-01-15T10:05:00.000Z", ) ] result = _determine_optimal_bin_size(incidents) # With less than 2 timestamps, should return default assert result == "10min" def test_determine_bin_size_hour_range(self): """Test with incidents spanning an hour.""" base_time = datetime(2025, 1, 15, 10, 0, 0) incidents = [] for i in range(30): # 30 incidents over 1 hour ts = (base_time + timedelta(minutes=i * 2)).isoformat() + "Z" incidents.append( IncidentData( incident_id=f"inc-{i:03d}", sensor_id="sensor-001", start_timestamp=ts, end_timestamp=ts, ) ) result = _determine_optimal_bin_size(incidents) # Should return a reasonable bin size assert result in ["1min", "10min", "1hr", "1day"] def test_determine_bin_size_day_range(self): """Test with incidents spanning multiple days.""" base_time = datetime(2025, 1, 1, 10, 0, 0) incidents = [] for i in range(30): # 30 incidents over 30 days ts = (base_time + timedelta(days=i)).isoformat() + "Z" incidents.append( IncidentData( incident_id=f"inc-{i:03d}", sensor_id="sensor-001", start_timestamp=ts, end_timestamp=ts, ) ) result = _determine_optimal_bin_size(incidents) # Should prefer larger bin sizes for longer ranges assert result in ["1hr", "1day"] def test_determine_bin_size_invalid_timestamps(self): """Test with invalid timestamps.""" incidents = [ IncidentData( incident_id="inc-001", sensor_id="sensor-001", start_timestamp="invalid", end_timestamp="invalid", ), IncidentData( incident_id="inc-002", sensor_id="sensor-001", start_timestamp="also-invalid", end_timestamp="also-invalid", ), ] result = _determine_optimal_bin_size(incidents) # With all invalid timestamps, should return default assert result == "10min" ================================================ FILE: agent/tests/unit_test/tools/test_prompt_gen.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for prompt_gen module.""" from vss_agents.tools.prompt_gen import PromptGenConfig from vss_agents.tools.prompt_gen import PromptGenInput class TestPromptGenConfig: """Test PromptGenConfig model.""" def test_with_required_field(self): config = PromptGenConfig(llm_name="test_llm") assert config.llm_name == "test_llm" assert config.prompt is not None # Has default value def test_custom_prompt(self): custom_prompt = "Custom prompt template" config = PromptGenConfig( llm_name="test_llm", prompt=custom_prompt, ) assert config.prompt == custom_prompt class TestPromptGenInput: """Test PromptGenInput model.""" def test_basic_input(self): input_data = PromptGenInput( user_query="What happened?", user_intent="Understand incident", ) assert input_data.user_query == "What happened?" assert input_data.user_intent == "Understand incident" assert input_data.detailed_thinking is False assert input_data.previous_prompt == "" def test_with_detailed_thinking(self): input_data = PromptGenInput( user_query="What happened?", user_intent="Understand incident", detailed_thinking=True, ) assert input_data.detailed_thinking is True def test_with_previous_prompt(self): input_data = PromptGenInput( user_query="What happened?", user_intent="Understand incident", previous_prompt="Previous prompt content", ) assert input_data.previous_prompt == "Previous prompt content" def test_all_fields(self): input_data = PromptGenInput( user_query="What happened?", user_intent="Understand incident", detailed_thinking=True, previous_prompt="Previous prompt", ) assert input_data.user_query == "What happened?" assert input_data.user_intent == "Understand incident" assert input_data.detailed_thinking is True assert input_data.previous_prompt == "Previous prompt" ================================================ FILE: agent/tests/unit_test/tools/test_prompt_gen_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for prompt_gen module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.prompt_gen import PromptGenConfig from vss_agents.tools.prompt_gen import PromptGenInput class TestPromptGenConfig: """Test PromptGenConfig model.""" def test_required_fields(self): config = PromptGenConfig(llm_name="test-llm") assert config.llm_name == "test-llm" assert config.prompt is not None # default prompt def test_custom_prompt(self): config = PromptGenConfig(llm_name="llm", prompt="Custom prompt") assert config.prompt == "Custom prompt" def test_missing_llm_raises(self): with pytest.raises(ValidationError): PromptGenConfig() class TestPromptGenInput: """Test PromptGenInput model.""" def test_required_fields(self): inp = PromptGenInput(user_query="What cars are in the video?", user_intent="find vehicles") assert inp.user_query == "What cars are in the video?" assert inp.user_intent == "find vehicles" assert inp.detailed_thinking is False assert inp.previous_prompt == "" def test_all_fields(self): inp = PromptGenInput( user_query="test query", user_intent="test intent", detailed_thinking=True, previous_prompt="Previous prompt text", ) assert inp.detailed_thinking is True assert inp.previous_prompt == "Previous prompt text" def test_missing_user_query_raises(self): with pytest.raises(ValidationError): PromptGenInput(user_intent="intent") def test_missing_user_intent_raises(self): with pytest.raises(ValidationError): PromptGenInput(user_query="query") ================================================ FILE: agent/tests/unit_test/tools/test_prompt_gen_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for prompt_gen inner function via generator invocation.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock import pytest from vss_agents.tools.prompt_gen import PromptGenConfig from vss_agents.tools.prompt_gen import PromptGenInput from vss_agents.tools.prompt_gen import prompt_gen class TestPromptGenInner: """Test the inner _prompt_gen function.""" @pytest.fixture def config(self): return PromptGenConfig( llm_name="test-llm", prompt="Generate a prompt for: {user_query} with intent: {user_intent}" ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_basic_prompt_gen(self, config, mock_builder): mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = "Generated prompt for finding cars" mock_llm.__or__ = MagicMock(return_value=AsyncMock(ainvoke=AsyncMock(return_value=mock_response))) mock_builder.get_llm.return_value = mock_llm gen = prompt_gen.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = PromptGenInput(user_query="find cars", user_intent="vehicle detection") result = await inner_fn(inp) assert isinstance(result, str) @pytest.mark.asyncio async def test_prompt_gen_with_detailed_thinking(self, config, mock_builder): mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = "Detailed prompt" mock_llm.__or__ = MagicMock(return_value=AsyncMock(ainvoke=AsyncMock(return_value=mock_response))) mock_builder.get_llm.return_value = mock_llm gen = prompt_gen.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = PromptGenInput(user_query="find cars", user_intent="detect", detailed_thinking=True) result = await inner_fn(inp) assert isinstance(result, str) @pytest.mark.asyncio async def test_prompt_gen_with_previous_prompt(self, config, mock_builder): mock_llm = MagicMock() mock_response1 = MagicMock() mock_response1.content = "New prompt" mock_response2 = MagicMock() mock_response2.content = "Merged prompt" call_count = [0] async def mock_ainvoke(*args, **kwargs): call_count[0] += 1 if call_count[0] == 1: return mock_response1 return mock_response2 mock_chain = AsyncMock(ainvoke=mock_ainvoke) mock_llm.__or__ = MagicMock(return_value=mock_chain) mock_builder.get_llm.return_value = mock_llm gen = prompt_gen.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = PromptGenInput( user_query="find cars", user_intent="detect", previous_prompt="Old prompt", ) result = await inner_fn(inp) assert isinstance(result, str) ================================================ FILE: agent/tests/unit_test/tools/test_python_executor.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for python_executor module.""" from pydantic import ValidationError import pytest from vss_agents.tools.code_executor.python_executor import CodeExecutorConfig from vss_agents.tools.code_executor.python_executor import CodeExecutorInput from vss_agents.tools.code_executor.python_executor import CodeExecutorOutput class TestCodeExecutorConfig: """Test CodeExecutorConfig model.""" def test_with_required_fields(self): config = CodeExecutorConfig( base_image="python:3.11-slim", language_packages=["numpy", "pandas"], ) assert config.backend == "docker" assert config.gpu is False assert config.base_image == "python:3.11-slim" assert config.language_packages == ["numpy", "pandas"] def test_with_gpu(self): config = CodeExecutorConfig( base_image="python:3.11-slim", language_packages=["numpy"], gpu=True, ) assert config.gpu is True def test_empty_packages(self): config = CodeExecutorConfig( base_image="python:3.11-slim", language_packages=[], ) assert config.language_packages == [] def test_missing_base_image_fails(self): with pytest.raises(ValidationError): CodeExecutorConfig(language_packages=["numpy"]) def test_missing_packages_fails(self): with pytest.raises(ValidationError): CodeExecutorConfig(base_image="python:3.11-slim") class TestCodeExecutorInput: """Test CodeExecutorInput model.""" def test_basic_input(self): input_data = CodeExecutorInput( code="print('hello')", files={}, ) assert input_data.code == "print('hello')" assert input_data.files == {} def test_with_files(self): input_data = CodeExecutorInput( code="import data", files={ "data.py": "x = 42", "config.json": '{"key": "value"}', }, ) assert len(input_data.files) == 2 assert "data.py" in input_data.files assert "config.json" in input_data.files def test_none_code(self): input_data = CodeExecutorInput( code=None, files={}, ) assert input_data.code is None def test_multiline_code(self): code = """ def hello(): return "Hello, World!" print(hello()) """ input_data = CodeExecutorInput(code=code, files={}) assert "def hello():" in input_data.code class TestCodeExecutorOutput: """Test CodeExecutorOutput model.""" def test_successful_output(self): output = CodeExecutorOutput(message="Hello, World!") assert output.message == "Hello, World!" def test_error_output(self): output = CodeExecutorOutput(message="Error: NameError: name 'undefined' is not defined") assert "Error" in output.message def test_multiline_output(self): output = CodeExecutorOutput(message="Line 1\nLine 2\nLine 3") assert "Line 1" in output.message assert "Line 2" in output.message def test_serialization(self): output = CodeExecutorOutput(message="Test output") data = output.model_dump() assert "message" in data assert data["message"] == "Test output" ================================================ FILE: agent/tests/unit_test/tools/test_python_executor_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for python_executor module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.code_executor.python_executor import CodeExecutorConfig from vss_agents.tools.code_executor.python_executor import CodeExecutorInput from vss_agents.tools.code_executor.python_executor import CodeExecutorOutput class TestCodeExecutorConfig: """Test CodeExecutorConfig model.""" def test_required_fields(self): config = CodeExecutorConfig( base_image="python:3.11-slim", language_packages=["numpy", "pandas"], ) assert config.backend == "docker" assert config.gpu is False assert config.base_image == "python:3.11-slim" assert config.language_packages == ["numpy", "pandas"] def test_with_gpu(self): config = CodeExecutorConfig( base_image="python:3.11-slim", language_packages=[], gpu=True, ) assert config.gpu is True def test_missing_base_image_raises(self): with pytest.raises(ValidationError): CodeExecutorConfig(language_packages=["numpy"]) def test_missing_packages_raises(self): with pytest.raises(ValidationError): CodeExecutorConfig(base_image="python:3.11-slim") class TestCodeExecutorInput: """Test CodeExecutorInput model.""" def test_with_code(self): inp = CodeExecutorInput( code="print('hello')", files={"main.py": "print('hello')"}, ) assert inp.code == "print('hello')" assert "main.py" in inp.files def test_no_code(self): inp = CodeExecutorInput(files={"data.csv": "a,b\n1,2"}) assert inp.code is None def test_empty_files(self): inp = CodeExecutorInput(files={}) assert inp.files == {} class TestCodeExecutorOutput: """Test CodeExecutorOutput model.""" def test_success_output(self): output = CodeExecutorOutput(message="hello world") assert output.message == "hello world" def test_error_output(self): output = CodeExecutorOutput(message="Error: exit code 1") assert "Error" in output.message def test_missing_message_raises(self): with pytest.raises(ValidationError): CodeExecutorOutput() ================================================ FILE: agent/tests/unit_test/tools/test_report_gen.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for report_gen module.""" from pydantic import ValidationError import pytest from vss_agents.tools.report_gen import ReportGenConfig from vss_agents.tools.report_gen import ReportGenInput from vss_agents.tools.report_gen import ReportGenOutput from vss_agents.tools.report_gen import _format_messages_to_markdown class TestReportGenConfig: """Test ReportGenConfig model.""" def test_with_required_field(self): config = ReportGenConfig(object_store="test-object-store") assert config.object_store == "test-object-store" assert config.output_dir == "/tmp/agent_reports" assert config.base_url is None assert config.save_local_copy is True assert config.template_path == "" assert config.llm_name == "" assert config.template_name is None assert config.report_prompt == "" def test_custom_values(self): config = ReportGenConfig( object_store="custom-store", output_dir="/custom/reports", base_url="http://example.com", save_local_copy=False, template_path="templates/report.html", llm_name="openai_llm", template_name="incident_report.html", report_prompt="Generate a report based on {messages} using {template}", ) assert config.output_dir == "/custom/reports" assert config.base_url == "http://example.com" assert config.save_local_copy is False assert config.template_path == "templates/report.html" assert config.llm_name == "openai_llm" assert config.template_name == "incident_report.html" assert "{messages}" in config.report_prompt class TestReportGenInput: """Test ReportGenInput model.""" def test_with_string_messages(self): input_data = ReportGenInput(messages="This is a summary report") assert input_data.messages == "This is a summary report" def test_with_list_messages(self): messages = [ {"role": "user", "content": "What happened?"}, {"role": "assistant", "content": "An incident occurred."}, ] input_data = ReportGenInput(messages=messages) assert len(input_data.messages) == 2 def test_with_empty_list(self): input_data = ReportGenInput(messages=[]) assert input_data.messages == [] def test_missing_messages_fails(self): with pytest.raises(ValidationError): ReportGenInput() class TestReportGenOutput: """Test ReportGenOutput model.""" def test_output_creation(self): output = ReportGenOutput( local_file_path="/tmp/reports/report_001.md", http_url="http://localhost:8000/static/reports/report_001.md", object_store_key="reports/report_001.md", summary="Incident report for sensor-001", file_size=1024, content="# Report\n\nThis is the report content.", ) assert output.local_file_path == "/tmp/reports/report_001.md" assert output.http_url == "http://localhost:8000/static/reports/report_001.md" assert output.object_store_key == "reports/report_001.md" assert output.summary == "Incident report for sensor-001" assert output.file_size == 1024 assert "# Report" in output.content def test_output_serialization(self): output = ReportGenOutput( local_file_path="/tmp/report.md", http_url="http://localhost/report.md", object_store_key="report.md", summary="Test summary", file_size=512, content="Test content", ) data = output.model_dump() assert "local_file_path" in data assert "http_url" in data assert "object_store_key" in data assert "summary" in data assert "file_size" in data assert "content" in data class TestFormatMessagesToMarkdown: """Test _format_messages_to_markdown function.""" def test_format_empty_messages(self): result = _format_messages_to_markdown([]) assert "# Deep Search Report" in result assert "Generated:" in result def test_format_dict_messages(self): messages = [ {"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there!"}, ] result = _format_messages_to_markdown(messages) assert "# Deep Search Report" in result assert "Message 1" in result assert "Message 2" in result assert "dict" in result def test_format_string_message(self): messages = ["This is a string message"] result = _format_messages_to_markdown(messages) assert "# Deep Search Report" in result def test_format_object_with_content(self): class MessageLike: def __init__(self, content): self.content = content messages = [MessageLike("Test message content")] result = _format_messages_to_markdown(messages) assert "# Deep Search Report" in result def test_format_nested_content(self): messages = [ {"role": "user", "content": [{"type": "text", "text": "Complex content"}]}, ] result = _format_messages_to_markdown(messages) assert "# Deep Search Report" in result ================================================ FILE: agent/tests/unit_test/tools/test_rtvi_vlm_alert.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for rtvi_vlm_alert module.""" from pydantic import ValidationError import pytest from vss_agents.tools.rtvi_vlm_alert import RTVIVLMAlertConfig from vss_agents.tools.rtvi_vlm_alert import RTVIVLMAlertInput from vss_agents.tools.rtvi_vlm_alert import RTVIVLMAlertOutput from vss_agents.tools.rtvi_vlm_alert import _sensor_to_rtvi_stream_id class TestRTVIVLMAlertConfig: """Test RTVIVLMAlertConfig model.""" def test_required_fields(self): config = RTVIVLMAlertConfig( rtvi_vlm_base_url="http://localhost:8000", vst_internal_url="http://10.0.0.1:30888", ) assert config.rtvi_vlm_base_url == "http://localhost:8000" assert config.vst_internal_url == "http://10.0.0.1:30888" assert config.default_model == "nvidia/cosmos-reason1-7b" assert config.default_chunk_duration == 20 assert config.default_fps == 1 assert config.timeout == 60 def test_custom_defaults(self): config = RTVIVLMAlertConfig( rtvi_vlm_base_url="http://localhost:8000", vst_internal_url="http://10.0.0.1:30888", default_model="custom-model", default_chunk_duration=10, default_fps=2, default_prompt="Detect collisions", default_system_prompt="You are a monitor", timeout=30, ) assert config.default_model == "custom-model" assert config.default_chunk_duration == 10 assert config.default_fps == 2 assert config.default_prompt == "Detect collisions" assert config.default_system_prompt == "You are a monitor" assert config.timeout == 30 def test_optional_va_tool(self): config = RTVIVLMAlertConfig( rtvi_vlm_base_url="http://localhost:8000", vst_internal_url="http://10.0.0.1:30888", va_get_incidents_tool="va_get_incidents", ) assert config.va_get_incidents_tool == "va_get_incidents" def test_missing_required_raises(self): with pytest.raises(ValidationError): RTVIVLMAlertConfig( rtvi_vlm_base_url="http://localhost:8000", ) class TestRTVIVLMAlertInput: """Test RTVIVLMAlertInput model.""" def test_start_action(self): inp = RTVIVLMAlertInput( action="start", sensor_name="HWY_20", prompt="Detect collisions", ) assert inp.action == "start" assert inp.sensor_name == "HWY_20" def test_stop_action(self): inp = RTVIVLMAlertInput(action="stop", sensor_name="HWY_20") assert inp.action == "stop" def test_get_incidents_action(self): inp = RTVIVLMAlertInput( action="get_incidents", sensor_name="HWY_20", start_time="2026-01-06T00:00:00.000Z", end_time="2026-01-07T00:00:00.000Z", max_count=5, incident_type="collision", ) assert inp.action == "get_incidents" assert inp.max_count == 5 assert inp.incident_type == "collision" def test_defaults(self): inp = RTVIVLMAlertInput(action="start") assert inp.sensor_name is None assert inp.prompt is None assert inp.system_prompt is None assert inp.start_time is None assert inp.end_time is None assert inp.max_count == 10 assert inp.incident_type is None def test_invalid_action_raises(self): with pytest.raises(ValidationError): RTVIVLMAlertInput(action="invalid") class TestRTVIVLMAlertOutput: """Test RTVIVLMAlertOutput model.""" def test_success_output(self): output = RTVIVLMAlertOutput( success=True, sensor_name="HWY_20", stream_id="uuid-123", message="Started monitoring", ) assert output.success is True assert output.stream_id == "uuid-123" def test_failure_output(self): output = RTVIVLMAlertOutput( success=False, message="sensor_name is required", ) assert output.success is False def test_incidents_output(self): output = RTVIVLMAlertOutput( success=True, sensor_name="HWY_20", message="Found 3 incidents", incidents=[{"id": "1"}, {"id": "2"}, {"id": "3"}], total_count=3, ) assert output.total_count == 3 assert len(output.incidents) == 3 def test_defaults(self): output = RTVIVLMAlertOutput(success=True, message="ok") assert output.sensor_name is None assert output.stream_id is None assert output.incidents is None assert output.total_count is None class TestSensorToRtviStreamIdMapping: """Test the in-memory sensor mapping.""" def test_mapping_is_dict(self): assert isinstance(_sensor_to_rtvi_stream_id, dict) ================================================ FILE: agent/tests/unit_test/tools/test_rtvi_vlm_alert_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for rtvi_vlm_alert inner function via generator invocation.""" import json from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import aiohttp import pytest from vss_agents.tools.rtvi_vlm_alert import RTVIVLMAlertConfig from vss_agents.tools.rtvi_vlm_alert import RTVIVLMAlertInput from vss_agents.tools.rtvi_vlm_alert import _sensor_to_rtvi_stream_id from vss_agents.tools.rtvi_vlm_alert import rtvi_vlm_alert class TestRTVIVLMAlertInner: """Test the inner _rtvi_vlm_alert function.""" @pytest.fixture def config(self): return RTVIVLMAlertConfig( rtvi_vlm_base_url="http://localhost:8000", vst_internal_url="http://10.0.0.1:30888", ) @pytest.fixture def mock_builder(self): return AsyncMock() async def _get_inner_fn(self, config, mock_builder): gen = rtvi_vlm_alert.__wrapped__(config, mock_builder) function_info = await gen.__anext__() return function_info.single_fn @pytest.mark.asyncio async def test_get_incidents_no_sensor_name(self, config, mock_builder): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="get_incidents") result = await inner_fn(inp) assert result.success is False assert "required" in result.message.lower() @pytest.mark.asyncio async def test_get_incidents_no_va_tool(self, config, mock_builder): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="get_incidents", sensor_name="HWY_20") result = await inner_fn(inp) assert result.success is False assert "not configured" in result.message.lower() @pytest.mark.asyncio async def test_get_incidents_with_va_tool(self, mock_builder): config = RTVIVLMAlertConfig( rtvi_vlm_base_url="http://localhost:8000", vst_internal_url="http://10.0.0.1:30888", va_get_incidents_tool="va_get_incidents", ) mock_va_tool = AsyncMock() mock_va_tool.ainvoke.return_value = {"incidents": [{"id": "1"}], "has_more": False} mock_builder.get_tool.return_value = mock_va_tool inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput( action="get_incidents", sensor_name="HWY_20", start_time="2026-01-06T00:00:00.000Z", end_time="2026-01-07T00:00:00.000Z", ) result = await inner_fn(inp) assert result.success is True assert result.total_count == 1 @pytest.mark.asyncio async def test_get_incidents_string_result(self, mock_builder): config = RTVIVLMAlertConfig( rtvi_vlm_base_url="http://localhost:8000", vst_internal_url="http://10.0.0.1:30888", va_get_incidents_tool="va_get_incidents", ) mock_va_tool = AsyncMock() mock_va_tool.ainvoke.return_value = json.dumps({"incidents": [{"id": "1"}, {"id": "2"}]}) mock_builder.get_tool.return_value = mock_va_tool inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="get_incidents", sensor_name="HWY_20") result = await inner_fn(inp) assert result.success is True assert result.total_count == 2 @pytest.mark.asyncio async def test_get_incidents_va_tool_error(self, mock_builder): config = RTVIVLMAlertConfig( rtvi_vlm_base_url="http://localhost:8000", vst_internal_url="http://10.0.0.1:30888", va_get_incidents_tool="va_get_incidents", ) mock_va_tool = AsyncMock() mock_va_tool.ainvoke.side_effect = RuntimeError("VA error") mock_builder.get_tool.return_value = mock_va_tool inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="get_incidents", sensor_name="HWY_20") result = await inner_fn(inp) assert result.success is False assert "Failed" in result.message @pytest.mark.asyncio async def test_start_no_sensor_name(self, config, mock_builder): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="start") result = await inner_fn(inp) assert result.success is False assert "required" in result.message.lower() @pytest.mark.asyncio async def test_stop_no_sensor_name(self, config, mock_builder): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="stop") result = await inner_fn(inp) assert result.success is False assert "required" in result.message.lower() @pytest.mark.asyncio async def test_start_sensor_not_found(self, config, mock_builder): mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.raise_for_status = MagicMock() mock_resp.text = AsyncMock( return_value=json.dumps([{"stream1": [{"name": "OTHER_SENSOR", "url": "rtsp://ip/stream"}]}]) ) mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_resp mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientSession", return_value=mock_session): with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientTimeout"): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="start", sensor_name="HWY_20") result = await inner_fn(inp) assert result.success is False assert "not found" in result.message.lower() @pytest.mark.asyncio async def test_stop_404_response(self, config, mock_builder): """Test stop when stream delete returns 404.""" _sensor_to_rtvi_stream_id["SENSOR_404"] = "rtvi-uuid-404" mock_delete_caption_resp = MagicMock() mock_delete_caption_resp.status = 200 mock_delete_caption_cm = AsyncMock() mock_delete_caption_cm.__aenter__ = AsyncMock(return_value=mock_delete_caption_resp) mock_delete_caption_cm.__aexit__ = AsyncMock(return_value=False) mock_delete_stream_resp = MagicMock() mock_delete_stream_resp.status = 404 mock_delete_stream_cm = AsyncMock() mock_delete_stream_cm.__aenter__ = AsyncMock(return_value=mock_delete_stream_resp) mock_delete_stream_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.delete.side_effect = [mock_delete_caption_cm, mock_delete_stream_cm] mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientTimeout"): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="stop", sensor_name="SENSOR_404") result = await inner_fn(inp) assert result.success is True assert "already stopped" in result.message.lower() @pytest.mark.asyncio async def test_stop_error_response(self, config, mock_builder): """Test stop when stream delete returns error.""" _sensor_to_rtvi_stream_id["SENSOR_ERR"] = "rtvi-uuid-err2" mock_delete_caption_resp = MagicMock() mock_delete_caption_resp.status = 200 mock_delete_caption_cm = AsyncMock() mock_delete_caption_cm.__aenter__ = AsyncMock(return_value=mock_delete_caption_resp) mock_delete_caption_cm.__aexit__ = AsyncMock(return_value=False) mock_delete_stream_resp = MagicMock() mock_delete_stream_resp.status = 500 mock_delete_stream_resp.text = AsyncMock(return_value="Internal error") mock_delete_stream_cm = AsyncMock() mock_delete_stream_cm.__aenter__ = AsyncMock(return_value=mock_delete_stream_resp) mock_delete_stream_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.delete.side_effect = [mock_delete_caption_cm, mock_delete_stream_cm] mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientTimeout"): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="stop", sensor_name="SENSOR_ERR") result = await inner_fn(inp) assert result.success is False assert "Failed" in result.message @pytest.mark.asyncio async def test_stop_caption_error_continues(self, config, mock_builder): """Test stop when caption deletion raises error but continues.""" _sensor_to_rtvi_stream_id["SENSOR_CAP_ERR"] = "rtvi-uuid-cap" mock_delete_caption_cm = AsyncMock() mock_delete_caption_cm.__aenter__ = AsyncMock(side_effect=RuntimeError("caption error")) mock_delete_caption_cm.__aexit__ = AsyncMock(return_value=False) mock_delete_stream_resp = MagicMock() mock_delete_stream_resp.status = 200 mock_delete_stream_cm = AsyncMock() mock_delete_stream_cm.__aenter__ = AsyncMock(return_value=mock_delete_stream_resp) mock_delete_stream_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.delete.side_effect = [mock_delete_caption_cm, mock_delete_stream_cm] mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientTimeout"): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="stop", sensor_name="SENSOR_CAP_ERR") result = await inner_fn(inp) assert result.success is True @pytest.mark.asyncio async def test_stop_no_active_alert(self, config, mock_builder): # Clear mapping _sensor_to_rtvi_stream_id.pop("MISSING_SENSOR", None) mock_session = MagicMock() mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientTimeout"): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="stop", sensor_name="MISSING_SENSOR") result = await inner_fn(inp) assert result.success is False assert "No active alert" in result.message @pytest.mark.asyncio async def test_stop_success(self, config, mock_builder): # Set up mapping _sensor_to_rtvi_stream_id["TEST_STOP"] = "rtvi-uuid-999" mock_delete_caption_resp = AsyncMock() mock_delete_caption_resp.status = 200 mock_delete_caption_resp.__aenter__ = AsyncMock(return_value=mock_delete_caption_resp) mock_delete_caption_resp.__aexit__ = AsyncMock(return_value=False) mock_delete_stream_resp = AsyncMock() mock_delete_stream_resp.status = 200 mock_delete_stream_resp.__aenter__ = AsyncMock(return_value=mock_delete_stream_resp) mock_delete_stream_resp.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.delete.side_effect = [mock_delete_caption_resp, mock_delete_stream_resp] mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientTimeout"): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="stop", sensor_name="TEST_STOP") result = await inner_fn(inp) assert result.success is True assert "stopped" in result.message.lower() @pytest.mark.asyncio async def test_connection_error(self, config, mock_builder): _sensor_to_rtvi_stream_id["ERR_SENSOR"] = "rtvi-uuid-err" mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("connection refused")) with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientTimeout"): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="stop", sensor_name="ERR_SENSOR") result = await inner_fn(inp) assert result.success is False assert "Connection error" in result.message @pytest.mark.asyncio async def test_generic_error(self, config, mock_builder): _sensor_to_rtvi_stream_id["GEN_ERR"] = "rtvi-uuid-gen" mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(side_effect=RuntimeError("something broke")) with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.rtvi_vlm_alert.aiohttp.ClientTimeout"): inner_fn = await self._get_inner_fn(config, mock_builder) inp = RTVIVLMAlertInput(action="stop", sensor_name="GEN_ERR") result = await inner_fn(inp) assert result.success is False ================================================ FILE: agent/tests/unit_test/tools/test_s3_picture_url.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for s3_picture_url module.""" from pydantic import ValidationError import pytest from vss_agents.tools.s3_picture_url import S3PictureURLConfig from vss_agents.tools.s3_picture_url import S3PictureURLInput from vss_agents.tools.s3_picture_url import S3PictureURLOutput class TestS3PictureURLConfig: """Test S3PictureURLConfig model.""" def test_defaults(self): config = S3PictureURLConfig() assert config.minio_url == "http://localhost:9000" assert config.access_key == "minioadmin" assert config.secret_key == "minioadmin" # pragma: allowlist secret assert config.bucket_name == "my-bucket" def test_custom_values(self): config = S3PictureURLConfig( minio_url="http://minio-server:9000", access_key="custom-access", secret_key="custom-secret", # pragma: allowlist secret bucket_name="custom-bucket", ) assert config.minio_url == "http://minio-server:9000" assert config.access_key == "custom-access" assert config.secret_key == "custom-secret" # pragma: allowlist secret assert config.bucket_name == "custom-bucket" class TestS3PictureURLInput: """Test S3PictureURLInput model.""" def test_valid_sensor_id(self): input_data = S3PictureURLInput(sensor_id="sensor-001") assert input_data.sensor_id == "sensor-001" def test_various_sensor_ids(self): sensor_ids = ["sensor-001", "camera_123", "stream-abc", "x"] for sid in sensor_ids: input_data = S3PictureURLInput(sensor_id=sid) assert input_data.sensor_id == sid def test_empty_sensor_id_fails(self): with pytest.raises(ValidationError): S3PictureURLInput(sensor_id="") def test_missing_sensor_id_fails(self): with pytest.raises(ValidationError): S3PictureURLInput() class TestS3PictureURLOutput: """Test S3PictureURLOutput model.""" def test_output_creation(self): output = S3PictureURLOutput( image_url="http://minio:9000/bucket/image.png", base64_frame="base64encodeddata==", video_url="http://minio:9000/bucket/video.mp4", ) assert output.image_url == "http://minio:9000/bucket/image.png" assert output.base64_frame == "base64encodeddata==" assert output.video_url == "http://minio:9000/bucket/video.mp4" def test_output_serialization(self): output = S3PictureURLOutput( image_url="http://example.com/image.png", base64_frame="SGVsbG8gV29ybGQ=", video_url="http://example.com/video.mp4", ) data = output.model_dump() assert "image_url" in data assert "base64_frame" in data assert "video_url" in data def test_output_various_urls(self): urls = [ ("http://localhost:9000/bucket/img.png", "data", "http://localhost:9000/bucket/vid.mp4"), ("https://s3.amazonaws.com/bucket/img.jpg", "base64", "https://s3.amazonaws.com/bucket/vid.mkv"), ("http://minio/assets/snapshot.png", "frame", "http://minio/assets/recording.mp4"), ] for img_url, b64, vid_url in urls: output = S3PictureURLOutput( image_url=img_url, base64_frame=b64, video_url=vid_url, ) assert output.image_url == img_url assert output.video_url == vid_url ================================================ FILE: agent/tests/unit_test/tools/test_search.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for search module.""" from datetime import UTC from datetime import datetime import json from unittest.mock import AsyncMock from unittest.mock import MagicMock from pydantic import ValidationError import pytest from vss_agents.tools.embed_search import EmbedSearchConfig from vss_agents.tools.embed_search import QueryInput from vss_agents.tools.embed_search import _str_input_converter from vss_agents.tools.search import QUERY_DECOMPOSITION_PROMPT from vss_agents.tools.search import DecomposedQuery from vss_agents.tools.search import SearchConfig from vss_agents.tools.search import SearchInput from vss_agents.tools.search import SearchOutput from vss_agents.tools.search import SearchResult from vss_agents.tools.search import decompose_query class TestSearchConfig: """Test SearchConfig model.""" def test_required_fields(self): config = SearchConfig( embed_search_tool="embed_search", agent_mode_llm="gpt-4o", vst_internal_url="http://localhost:30888", ) assert config.embed_search_tool == "embed_search" assert config.agent_mode_llm == "gpt-4o" assert config.vst_internal_url == "http://localhost:30888" assert "query" in config.agent_mode_prompt def test_custom_prompt(self): config = SearchConfig( embed_search_tool="embed_search", agent_mode_llm="gpt-4o", vst_internal_url="http://localhost:30888", agent_mode_prompt="Custom prompt for analysis", ) assert config.agent_mode_prompt == "Custom prompt for analysis" def test_fusion_method_defaults(self): """Test that fusion method defaults are set correctly.""" config = SearchConfig( embed_search_tool="embed_search", agent_mode_llm="gpt-4o", vst_internal_url="http://localhost:30888", ) assert config.fusion_method == "rrf" assert config.w_attribute == 0.55 assert config.w_embed == 0.35 assert config.rrf_k == 60 assert config.rrf_w == 0.5 def test_fusion_method_weighted_linear(self): """Test weighted linear fusion configuration.""" config = SearchConfig( embed_search_tool="embed_search", agent_mode_llm="gpt-4o", vst_internal_url="http://localhost:30888", fusion_method="weighted_linear", w_attribute=0.6, w_embed=0.4, ) assert config.fusion_method == "weighted_linear" assert config.w_attribute == 0.6 assert config.w_embed == 0.4 def test_fusion_method_rrf_custom(self): """Test RRF fusion with custom parameters.""" config = SearchConfig( embed_search_tool="embed_search", agent_mode_llm="gpt-4o", vst_internal_url="http://localhost:30888", fusion_method="rrf", rrf_k=100, rrf_w=0.7, ) assert config.fusion_method == "rrf" assert config.rrf_k == 100 assert config.rrf_w == 0.7 class TestSearchInput: """Test SearchInput model.""" def test_required_fields(self): input_data = SearchInput( query="find a person", source_type="video_file", agent_mode=True, ) assert input_data.query == "find a person" assert input_data.agent_mode is True def test_all_fields(self): input_data = SearchInput( query="find cars", source_type="rtsp", video_sources=["video1", "video2"], description="parking lot", timestamp_start=datetime(2025, 1, 15, 10, 0, 0, tzinfo=UTC), timestamp_end=datetime(2025, 1, 15, 11, 0, 0, tzinfo=UTC), top_k=10, min_cosine_similarity=0.5, agent_mode=False, ) assert input_data.query == "find cars" assert input_data.video_sources == ["video1", "video2"] assert input_data.description == "parking lot" assert input_data.top_k == 10 assert input_data.min_cosine_similarity == 0.5 assert input_data.agent_mode is False def test_defaults(self): input_data = SearchInput( query="test query", source_type="video_file", agent_mode=True, ) assert input_data.video_sources is None assert input_data.description is None assert input_data.timestamp_start is None assert input_data.timestamp_end is None assert input_data.top_k is None # return all mathing results assert input_data.min_cosine_similarity == 0.0 def test_missing_query_raises(self): with pytest.raises(ValidationError): SearchInput(source_type="video_file", agent_mode=True) def test_missing_agent_mode_raises(self): with pytest.raises(ValidationError): SearchInput(query="test", source_type="video_file") def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): SearchInput( query="test", source_type="video_file", agent_mode=True, extra_field="not allowed", ) class TestSearchResult: """Test SearchResult model.""" def test_valid_result(self): result = SearchResult( video_name="video1.mp4", description="A video of a parking lot", start_time="2025-01-15T10:00:00Z", end_time="2025-01-15T10:01:00Z", sensor_id="21908c9a-bd40-4941-8a2e-79bc0880fb5a", screenshot_url="http://example.com/screenshot1.jpg", similarity=0.95, ) assert result.video_name == "video1.mp4" assert result.description == "A video of a parking lot" assert result.start_time == "2025-01-15T10:00:00Z" assert result.end_time == "2025-01-15T10:01:00Z" assert result.sensor_id == "21908c9a-bd40-4941-8a2e-79bc0880fb5a" assert result.screenshot_url == "http://example.com/screenshot1.jpg" assert result.similarity == 0.95 def test_missing_required_field_raises(self): with pytest.raises(ValidationError): SearchResult( video_name="video1.mp4", # Missing other required fields ) class TestSearchOutput: """Test SearchOutput model.""" def test_empty_data(self): output = SearchOutput() assert output.data == [] def test_with_results(self): result1 = SearchResult( video_name="video1.mp4", description="Description 1", start_time="2025-01-15T10:00:00Z", end_time="2025-01-15T10:01:00Z", sensor_id="sensor-1", screenshot_url="http://example.com/screenshot1.jpg", similarity=0.95, ) result2 = SearchResult( video_name="video2.mp4", description="Description 2", start_time="2025-01-15T11:00:00Z", end_time="2025-01-15T11:01:00Z", sensor_id="sensor-2", screenshot_url="http://example.com/screenshot2.jpg", similarity=0.85, ) output = SearchOutput(data=[result1, result2]) assert len(output.data) == 2 assert output.data[0].video_name == "video1.mp4" assert output.data[1].video_name == "video2.mp4" def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): SearchOutput( data=[], extra_field="not allowed", ) def test_serialization(self): result = SearchResult( video_name="video1.mp4", description="Test", start_time="2025-01-15T10:00:00Z", end_time="2025-01-15T10:01:00Z", sensor_id="sensor-1", screenshot_url="http://example.com/screenshot1.jpg", similarity=0.9, ) output = SearchOutput(data=[result]) json_str = output.model_dump_json() assert "video1.mp4" in json_str assert "0.9" in json_str class TestQueryInput: """Test QueryInput model.""" def test_defaults(self): qi = QueryInput(source_type="video_file") assert qi.id == "" assert qi.params == {} assert qi.prompts == {} assert qi.response == "" assert qi.embeddings == [] assert qi.source_type == "video_file" def test_with_values(self): qi = QueryInput( id="input1", params={"query": "find person"}, prompts={"system": "analyze"}, response="result", embeddings=[{"vector": [0.1, 0.2]}], source_type="rtsp", ) assert qi.id == "input1" assert qi.params["query"] == "find person" assert qi.source_type == "rtsp" class TestEmbedSearchConfig: """Test EmbedSearchConfig model.""" def test_required_fields(self): config = EmbedSearchConfig( cosmos_embed_endpoint="http://localhost:8080", es_endpoint="http://localhost:9200", vst_external_url="http://localhost:8081", ) assert config.cosmos_embed_endpoint == "http://localhost:8080" assert config.es_endpoint == "http://localhost:9200" assert config.es_index == "video_embeddings" assert config.vst_external_url == "http://localhost:8081" def test_custom_index(self): config = EmbedSearchConfig( cosmos_embed_endpoint="http://localhost:8080", es_endpoint="http://localhost:9200", vst_external_url="http://localhost:8081", es_index="custom_index", ) assert config.es_index == "custom_index" class TestStrInputConverter: """Test _str_input_converter function.""" def test_json_with_params(self): input_str = '{"params": {"query": "find cars"}, "source_type": "video_file"}' result = _str_input_converter(input_str) assert result.params["query"] == "find cars" assert result.source_type == "video_file" def test_json_with_prompts(self): input_str = '{"prompts": {"system": "analyze"}, "source_type": "rtsp"}' result = _str_input_converter(input_str) assert result.prompts["system"] == "analyze" assert result.source_type == "rtsp" def test_invalid_json_format(self): input_str = "not valid json" result = _str_input_converter(input_str) assert result.params["query"] == "not valid json" def test_json_without_params_or_prompts(self): input_str = '{"other_field": "value"}' result = _str_input_converter(input_str) # Should treat entire input as query string assert result.params["query"] == '{"other_field": "value"}' class TestDecomposedQuery: """Test DecomposedQuery model.""" def test_defaults(self): dq = DecomposedQuery() assert dq.query == "" assert dq.video_sources == [] assert dq.source_type == "video_file" assert dq.timestamp_start is None assert dq.timestamp_end is None assert dq.attributes == [] assert dq.top_k is None assert dq.min_cosine_similarity is None def test_with_values(self): dq = DecomposedQuery( query="man pushing cart", video_sources=["Endeavor heart"], source_type="stream", timestamp_start="2025-01-01T13:00:00Z", timestamp_end="2025-01-01T14:00:00Z", attributes=["man", "beige shirt"], top_k=10, min_cosine_similarity=0.7, ) assert dq.query == "man pushing cart" assert dq.video_sources == ["Endeavor heart"] assert dq.source_type == "stream" assert dq.timestamp_start == "2025-01-01T13:00:00Z" assert dq.timestamp_end == "2025-01-01T14:00:00Z" assert dq.attributes == ["man", "beige shirt"] assert dq.top_k == 10 assert dq.min_cosine_similarity == 0.7 def test_with_negative_min_cosine_similarity(self): """Test that negative min_cosine_similarity values are valid (-1.0 to 1.0 range).""" dq = DecomposedQuery( query="any match", min_cosine_similarity=-0.5, ) assert dq.min_cosine_similarity == -0.5 class TestQueryDecompositionPrompt: """Test QUERY_DECOMPOSITION_PROMPT constant.""" def test_prompt_has_placeholders(self): assert "{video_sources}" in QUERY_DECOMPOSITION_PROMPT assert "{few_shot_examples}" in QUERY_DECOMPOSITION_PROMPT assert "{user_query}" in QUERY_DECOMPOSITION_PROMPT def test_prompt_contains_instructions(self): assert "query" in QUERY_DECOMPOSITION_PROMPT.lower() assert "video_sources" in QUERY_DECOMPOSITION_PROMPT assert "source_type" in QUERY_DECOMPOSITION_PROMPT assert "timestamp_start" in QUERY_DECOMPOSITION_PROMPT assert "timestamp_end" in QUERY_DECOMPOSITION_PROMPT assert "attributes" in QUERY_DECOMPOSITION_PROMPT assert "top_k" in QUERY_DECOMPOSITION_PROMPT assert "min_cosine_similarity" in QUERY_DECOMPOSITION_PROMPT assert "-1.0" in QUERY_DECOMPOSITION_PROMPT # Verify correct range is documented class TestDecomposeQuery: """Test decompose_query function.""" @pytest.fixture def mock_llm(self): """Create a mock LLM for testing.""" llm = MagicMock() llm.ainvoke = AsyncMock() return llm @pytest.mark.asyncio async def test_simple_query(self, mock_llm): """Test decomposition of a simple search query.""" mock_llm.ainvoke.return_value = MagicMock( content='{"query": "red car", "video_sources": [], "source_type": "video_file", "attributes": ["red", "car"]}' ) result = await decompose_query("find a red car", mock_llm) assert result.query == "red car" assert result.video_sources == [] assert result.source_type == "video_file" assert result.attributes == ["red", "car"] @pytest.mark.asyncio async def test_query_with_time_range(self, mock_llm): """Test decomposition with time range extraction.""" mock_llm.ainvoke.return_value = MagicMock( content='{"query": "person walking", "timestamp_start": "2025-01-01T09:00:00Z", "timestamp_end": "2025-01-01T10:00:00Z"}' ) result = await decompose_query("find person walking between 9am and 10am", mock_llm) assert result.query == "person walking" assert result.timestamp_start == "2025-01-01T09:00:00Z" assert result.timestamp_end == "2025-01-01T10:00:00Z" @pytest.mark.asyncio async def test_query_with_video_sources(self, mock_llm): """Test decomposition with video source extraction.""" mock_llm.ainvoke.return_value = MagicMock( content='{"query": "delivery truck", "video_sources": ["warehouse entrance", "parking lot"], "source_type": "stream"}' ) result = await decompose_query( "find delivery truck at warehouse entrance or parking lot camera", mock_llm, video_stream_names=["warehouse entrance", "parking lot", "main gate"], ) assert result.query == "delivery truck" assert result.video_sources == ["warehouse entrance", "parking lot"] assert result.source_type == "stream" @pytest.mark.asyncio async def test_complex_query_all_parameters(self, mock_llm): """Test decomposition of complex query with all parameters.""" mock_llm.ainvoke.return_value = MagicMock( content="""{ "query": "man pushing cart", "video_sources": ["Endeavor heart"], "source_type": "stream", "timestamp_start": "2025-01-01T13:00:00Z", "timestamp_end": "2025-01-01T14:00:00Z", "attributes": ["man", "beige shirt"] }""" ) result = await decompose_query( "Find a man pushing a cart wearing a beige shirt between 1 pm and 2 pm at Endeavor heart", mock_llm, video_stream_names=["Endeavor heart", "Building A"], ) assert result.query == "man pushing cart" assert result.video_sources == ["Endeavor heart"] assert result.source_type == "stream" assert result.timestamp_start == "2025-01-01T13:00:00Z" assert result.timestamp_end == "2025-01-01T14:00:00Z" assert result.attributes == ["man", "beige shirt"] @pytest.mark.asyncio async def test_query_with_json_code_block(self, mock_llm): """Test parsing JSON wrapped in markdown code blocks.""" mock_llm.ainvoke.return_value = MagicMock( content='```json\n{"query": "blue car", "attributes": ["blue", "car"]}\n```' ) result = await decompose_query("find blue car", mock_llm) assert result.query == "blue car" assert result.attributes == ["blue", "car"] @pytest.mark.asyncio async def test_query_with_plain_code_block(self, mock_llm): """Test parsing JSON wrapped in plain code blocks.""" mock_llm.ainvoke.return_value = MagicMock( content='```\n{"query": "person running", "source_type": "video_file"}\n```' ) result = await decompose_query("find person running", mock_llm) assert result.query == "person running" assert result.source_type == "video_file" @pytest.mark.asyncio async def test_fallback_on_invalid_json(self, mock_llm): """Test fallback to original query when LLM returns invalid JSON.""" mock_llm.ainvoke.return_value = MagicMock(content="This is not valid JSON") result = await decompose_query("find a dog", mock_llm) assert result.query == "find a dog" assert result.video_sources == [] assert result.source_type == "video_file" @pytest.mark.asyncio async def test_fallback_on_llm_exception(self, mock_llm): """Test fallback when LLM raises an exception.""" mock_llm.ainvoke.side_effect = Exception("LLM service unavailable") result = await decompose_query("find a cat", mock_llm) assert result.query == "find a cat" assert result.video_sources == [] @pytest.mark.asyncio async def test_with_video_file_names(self, mock_llm): """Test providing video file names as context.""" mock_llm.ainvoke.return_value = MagicMock( content='{"query": "accident scene", "video_sources": ["highway_cam.mp4"], "source_type": "video_file"}' ) result = await decompose_query( "find accident in highway_cam video", mock_llm, video_file_names=["highway_cam.mp4", "parking_lot.mp4"], ) assert result.query == "accident scene" assert result.video_sources == ["highway_cam.mp4"] assert result.source_type == "video_file" @pytest.mark.asyncio async def test_empty_response_fields(self, mock_llm): """Test handling of null/empty fields in response.""" mock_llm.ainvoke.return_value = MagicMock( content='{"query": "test", "video_sources": null, "attributes": null, "source_type": null}' ) result = await decompose_query("test query", mock_llm) assert result.query == "test" assert result.video_sources == [] assert result.attributes == [] assert result.source_type == "video_file" @pytest.mark.asyncio async def test_custom_few_shot_examples(self, mock_llm): """Test using custom few-shot examples.""" mock_llm.ainvoke.return_value = MagicMock(content='{"query": "forklift", "source_type": "stream"}') custom_examples = """Example: User query: "Find forklift" Output: {"query": "forklift", "source_type": "stream"}""" result = await decompose_query( "find forklift", mock_llm, few_shot_examples=custom_examples, ) assert result.query == "forklift" @pytest.mark.asyncio async def test_query_with_only_attributes(self, mock_llm): """Test query that extracts only attributes.""" mock_llm.ainvoke.return_value = MagicMock( content='{"query": "person with backpack", "attributes": ["person", "blue backpack", "hat"]}' ) result = await decompose_query("find a person with a blue backpack and hat", mock_llm) assert result.query == "person with backpack" assert "blue backpack" in result.attributes assert "hat" in result.attributes @pytest.mark.asyncio async def test_partial_time_range(self, mock_llm): """Test query with only start time specified.""" mock_llm.ainvoke.return_value = MagicMock( content='{"query": "security guard", "timestamp_start": "2025-01-01T08:00:00Z"}' ) result = await decompose_query("find security guard after 8am", mock_llm) assert result.query == "security guard" assert result.timestamp_start == "2025-01-01T08:00:00Z" assert result.timestamp_end is None @pytest.mark.asyncio async def test_query_with_top_k(self, mock_llm): """Test extraction of top_k from query.""" mock_llm.ainvoke.return_value = MagicMock(content='{"query": "red car", "top_k": 5}') result = await decompose_query("find top 5 red cars", mock_llm) assert result.query == "red car" assert result.top_k == 5 @pytest.mark.asyncio async def test_query_with_min_cosine_similarity(self, mock_llm): """Test extraction of min_cosine_similarity from query.""" mock_llm.ainvoke.return_value = MagicMock(content='{"query": "person running", "min_cosine_similarity": 0.8}') result = await decompose_query("find highly similar matches of person running", mock_llm) assert result.query == "person running" assert result.min_cosine_similarity == 0.8 @pytest.mark.asyncio async def test_query_with_all_filtering_params(self, mock_llm): """Test extraction of both top_k and min_cosine_similarity.""" mock_llm.ainvoke.return_value = MagicMock( content='{"query": "blue truck", "top_k": 10, "min_cosine_similarity": 0.7}' ) result = await decompose_query("find top 10 highly similar blue trucks", mock_llm) assert result.query == "blue truck" assert result.top_k == 10 assert result.min_cosine_similarity == 0.7 @pytest.mark.asyncio async def test_invalid_top_k_ignored(self, mock_llm): """Test that invalid top_k values are ignored.""" mock_llm.ainvoke.return_value = MagicMock(content='{"query": "car", "top_k": "invalid"}') result = await decompose_query("find cars", mock_llm) assert result.query == "car" assert result.top_k is None @pytest.mark.asyncio async def test_invalid_min_cosine_similarity_ignored(self, mock_llm): """Test that invalid min_cosine_similarity values are ignored.""" mock_llm.ainvoke.return_value = MagicMock(content='{"query": "car", "min_cosine_similarity": "high"}') result = await decompose_query("find similar cars", mock_llm) assert result.query == "car" assert result.min_cosine_similarity is None @pytest.mark.asyncio async def test_negative_min_cosine_similarity(self, mock_llm): """Test extraction of negative min_cosine_similarity (valid range is -1.0 to 1.0).""" mock_llm.ainvoke.return_value = MagicMock(content='{"query": "any object", "min_cosine_similarity": -0.5}') result = await decompose_query("find any matching objects", mock_llm) assert result.query == "any object" assert result.min_cosine_similarity == -0.5 class TestQueryInputSourceType: """Test QueryInput source_type field.""" def test_source_type_required(self): with pytest.raises(ValidationError): QueryInput() def test_source_type_rtsp(self): qi = QueryInput(source_type="rtsp") assert qi.source_type == "rtsp" def test_source_type_video_file(self): qi = QueryInput(source_type="video_file") assert qi.source_type == "video_file" def test_source_type_in_serialization(self): qi = QueryInput( id="test", params={"query": "test"}, source_type="rtsp", ) json_str = qi.model_dump_json() parsed = json.loads(json_str) assert parsed["source_type"] == "rtsp" ================================================ FILE: agent/tests/unit_test/tools/test_search_converters.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for search converters and remaining inner function edge cases.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock import pytest from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.embed_search import EmbedSearchResultItem from vss_agents.tools.search import SearchConfig from vss_agents.tools.search import SearchInput from vss_agents.tools.search import SearchOutput from vss_agents.tools.search import SearchResult from vss_agents.tools.search import search def _make_embed_output(results): """Helper to build an EmbedSearchOutput.""" items = [] for r in results: items.append( EmbedSearchResultItem( video_name=r.get("video_name", ""), description=r.get("description", ""), start_time=r.get("start_time", ""), end_time=r.get("end_time", ""), sensor_id=r.get("sensor_id", "s1"), screenshot_url=r.get("screenshot_url", ""), similarity_score=float(r.get("similarity_score", 0.0)), ) ) return EmbedSearchOutput(query_embedding=[0.1, 0.2], results=items) class TestSearchConverters: """Test search converter functions via registered converters.""" @pytest.fixture def config(self): return SearchConfig( embed_search_tool="embed_search", agent_mode_llm="gpt-4o", vst_internal_url="http://localhost:30888" ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_str_input_converter(self, config, mock_builder): mock_builder.get_function.return_value = AsyncMock() mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() # Find converters (they're registered functions) converters = fi.converters assert len(converters) >= 4 # Test str converter (first in list) str_converter = converters[0] result = str_converter('{"query": "test", "source_type": "video_file", "agent_mode": true}') assert isinstance(result, SearchInput) assert result.query == "test" @pytest.mark.asyncio async def test_chat_request_converter(self, config, mock_builder): mock_builder.get_function.return_value = AsyncMock() mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() converters = fi.converters chat_request_converter = converters[1] mock_message = MagicMock() mock_message.content = '{"query": "find car", "source_type": "video_file", "agent_mode": false}' mock_request = MagicMock() mock_request.messages = [mock_message] result = chat_request_converter(mock_request) assert isinstance(result, SearchInput) assert result.query == "find car" @pytest.mark.asyncio async def test_chat_request_converter_error(self, config, mock_builder): mock_builder.get_function.return_value = AsyncMock() mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() converters = fi.converters chat_request_converter = converters[1] mock_message = MagicMock() mock_message.content = "not valid json" mock_request = MagicMock() mock_request.messages = [mock_message] with pytest.raises(Exception): chat_request_converter(mock_request) @pytest.mark.asyncio async def test_output_converter(self, config, mock_builder): mock_builder.get_function.return_value = AsyncMock() mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() converters = fi.converters output_converter = converters[2] output = SearchOutput( data=[ SearchResult( video_name="v.mp4", description="d", start_time="t1", end_time="t2", sensor_id="s1", screenshot_url="s", similarity=0.9, ) ] ) result = output_converter(output) assert isinstance(result, str) assert "v.mp4" in result @pytest.mark.asyncio async def test_chat_response_converter(self, config, mock_builder): mock_builder.get_function.return_value = AsyncMock() mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() converters = fi.converters chat_response_converter = converters[3] output = SearchOutput(data=[]) result = chat_response_converter(output) assert result is not None @pytest.mark.asyncio async def test_chat_response_chunk_converter(self, config, mock_builder): mock_builder.get_function.return_value = AsyncMock() mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() converters = fi.converters chat_chunk_converter = converters[4] output = SearchOutput(data=[]) result = chat_chunk_converter(output) assert result is not None @pytest.mark.asyncio async def test_search_dict_output(self, config, mock_builder): """Test when embed_search returns a dict.""" embed_output = _make_embed_output([]) embed_dict = embed_output.model_dump() mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_dict mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_embed_error_with_meta(self, config, mock_builder): """Test error with meta.status attribute.""" from fastapi import HTTPException err = RuntimeError("ES error") err.meta = MagicMock() err.meta.status = 429 mock_embed = AsyncMock() mock_embed.ainvoke.side_effect = err mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) with pytest.raises(HTTPException) as exc_info: await inner_fn(inp) assert exc_info.value.status_code == 429 @pytest.mark.asyncio async def test_search_embed_error_with_int_arg(self, config, mock_builder): """Test error with int first arg.""" from fastapi import HTTPException err = RuntimeError(502) mock_embed = AsyncMock() mock_embed.ainvoke.side_effect = err mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) with pytest.raises(HTTPException) as exc_info: await inner_fn(inp) assert exc_info.value.status_code == 502 @pytest.mark.asyncio async def test_search_sensor_description_fallback(self, config, mock_builder): """Test that description from EmbedSearchResultItem is used.""" embed_output = _make_embed_output( [ { "video_name": "cam.mp4", "description": "Front entrance", "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", "similarity_score": 0.9, } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert result.data[0].description == "Front entrance" @pytest.mark.asyncio async def test_search_invalid_end_time_iso(self, config, mock_builder): """Test handling of result with end_time.""" embed_output = _make_embed_output( [ { "video_name": "cam.mp4", "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", "similarity_score": 0.9, } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert len(result.data) == 1 @pytest.mark.asyncio async def test_search_no_base_timestamp(self, config, mock_builder): """Test when no base timestamp is available.""" embed_output = _make_embed_output( [ { "video_name": "cam.mp4", "start_time": "", "end_time": "", "similarity_score": 0.9, } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert isinstance(result, SearchOutput) assert len(result.data) == 1 ================================================ FILE: agent/tests/unit_test/tools/test_search_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for search module to improve coverage.""" from datetime import UTC from datetime import datetime import json from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.embed_search import EmbedSearchResultItem from vss_agents.tools.search import SearchInput from vss_agents.tools.search import SearchOutput from vss_agents.tools.search import SearchResult class TestSearchInputConversion: """Test SearchInput conversion from JSON.""" def test_json_str_conversion(self): json_str = '{"query": "test", "source_type": "video_file", "agent_mode": false}' result = SearchInput.model_validate_json(json_str) assert result.query == "test" assert result.agent_mode is False def test_json_with_all_fields(self): json_str = json.dumps( { "query": "find cars", "source_type": "video_file", "video_sources": ["video1"], "description": "parking", "timestamp_start": "2025-01-15T10:00:00Z", "timestamp_end": "2025-01-15T11:00:00Z", "top_k": 5, "min_cosine_similarity": 0.5, "agent_mode": True, } ) result = SearchInput.model_validate_json(json_str) assert result.query == "find cars" assert result.video_sources == ["video1"] assert result.top_k == 5 class TestSearchOutputSerialization: """Test SearchOutput serialization and deserialization.""" def test_round_trip_serialization(self): result = SearchResult( video_name="test.mp4", description="test video", start_time="2025-01-01T00:00:00Z", end_time="2025-01-01T01:00:00Z", sensor_id="s1", screenshot_url="http://example.com/screenshot.jpg", similarity=0.9, ) output = SearchOutput(data=[result]) json_str = output.model_dump_json() parsed = SearchOutput.model_validate_json(json_str) assert len(parsed.data) == 1 assert parsed.data[0].video_name == "test.mp4" assert parsed.data[0].similarity == 0.9 class TestEmbedSearchOutputConversion: """Test EmbedSearchOutput data structure and conversion to SearchResult.""" def test_embed_search_result_item_to_search_result(self): """Test conversion from EmbedSearchResultItem to SearchResult.""" item = EmbedSearchResultItem( video_name="camera1.mp4", description="Parking lot", start_time="2025-01-15T10:00:00Z", end_time="2025-01-15T10:30:00Z", sensor_id="s1", screenshot_url="http://example.com/screenshot.jpg", similarity_score=0.95, ) # Simulate what search does search_result = SearchResult( video_name=item.video_name, description=item.description, start_time=item.start_time, end_time=item.end_time, sensor_id=item.sensor_id, screenshot_url=item.screenshot_url, similarity=item.similarity_score, ) assert search_result.similarity == 0.95 assert search_result.video_name == "camera1.mp4" def test_embed_search_output_with_results(self): """Test EmbedSearchOutput with multiple results.""" items = [ EmbedSearchResultItem( video_name="v1.mp4", similarity_score=0.9, ), EmbedSearchResultItem( video_name="v2.mp4", similarity_score=0.8, ), ] output = EmbedSearchOutput(query_embedding=[0.1, 0.2], results=items) assert len(output.results) == 2 assert output.results[0].video_name == "v1.mp4" assert output.results[1].similarity_score == 0.8 def test_embed_search_output_empty(self): """Test empty EmbedSearchOutput.""" output = EmbedSearchOutput(query_embedding=[], results=[]) assert len(output.results) == 0 def test_search_result_with_none_similarity(self): """Test handling None similarity_score.""" item = EmbedSearchResultItem( video_name="test.mp4", similarity_score=0.0, ) assert item.similarity_score == 0.0 def test_search_result_empty_video_name_skipped(self): """Test that empty video_name results are identified.""" item = EmbedSearchResultItem(video_name="", similarity_score=0.9) assert not item.video_name # Should be skipped by search def test_end_time_iso_string_parsing(self): """Test parsing ISO string end_time.""" end_time_value = "2025-01-15T10:30:00Z" end_dt = datetime.fromisoformat(end_time_value.replace("Z", "+00:00")) if end_dt.tzinfo is None: end_dt = end_dt.replace(tzinfo=UTC) end_time_iso = end_dt.isoformat().replace("+00:00", "Z") assert "2025-01-15" in end_time_iso def test_end_time_default_value(self): """Test handling non-str non-float end_time.""" end_time_value = None base_dt = datetime(2025, 1, 15, 10, 0, 0, tzinfo=UTC) if isinstance(end_time_value, str): end_time_iso = end_time_value elif isinstance(end_time_value, int | float): end_time_iso = "computed" else: end_time_iso = base_dt.isoformat().replace("+00:00", "Z") assert "2025-01-15" in end_time_iso def test_start_time_invalid_iso_string(self): """Test handling invalid ISO start_time string.""" start_time_value = "not-a-date" base_dt = datetime(2025, 1, 15, 10, 0, 0, tzinfo=UTC) try: start_dt = datetime.fromisoformat(start_time_value.replace("Z", "+00:00")) start_time_iso = start_dt.isoformat() except Exception: start_time_iso = base_dt.isoformat().replace("+00:00", "Z") assert "2025-01-15" in start_time_iso def test_screenshot_url_fallback_to_empty(self): """Test that screenshot_url defaults to empty string.""" item = EmbedSearchResultItem(video_name="test.mp4") assert item.screenshot_url == "" def test_parse_base_timestamp_invalid(self): """Test parsing invalid base timestamp.""" import contextlib base_timestamp_str = "invalid-timestamp" base_dt = None with contextlib.suppress(Exception): base_dt = datetime.fromisoformat(str(base_timestamp_str).replace("Z", "+00:00")) assert base_dt is None def test_embed_search_output_serialization_round_trip(self): """Test round-trip serialization of EmbedSearchOutput.""" item = EmbedSearchResultItem( video_name="v.mp4", description="desc", start_time="2025-01-01T00:00:00Z", end_time="2025-01-01T01:00:00Z", sensor_id="s1", screenshot_url="http://pic.jpg", similarity_score=0.85, ) output = EmbedSearchOutput(query_embedding=[0.1, 0.2], results=[item]) json_str = output.model_dump_json() parsed = EmbedSearchOutput.model_validate_json(json_str) assert len(parsed.results) == 1 assert parsed.results[0].video_name == "v.mp4" assert parsed.results[0].similarity_score == 0.85 ================================================ FILE: agent/tests/unit_test/tools/test_search_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for search inner function via generator invocation.""" import json from unittest.mock import AsyncMock from unittest.mock import MagicMock import pytest from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.embed_search import EmbedSearchResultItem from vss_agents.tools.search import SearchConfig from vss_agents.tools.search import SearchInput from vss_agents.tools.search import SearchOutput from vss_agents.tools.search import search def _make_embed_output_with_results(results): """Helper to build an EmbedSearchOutput with search results.""" items = [] for r in results: items.append( EmbedSearchResultItem( video_name=r.get("video_name", ""), description=r.get("description", ""), start_time=r.get("start_time", ""), end_time=r.get("end_time", ""), sensor_id=r.get("sensor_id", "s1"), screenshot_url=r.get("screenshot_url", ""), similarity_score=float(r.get("similarity_score", 0.0)), ) ) return EmbedSearchOutput(query_embedding=[0.1, 0.2, 0.3], results=items) class TestSearchInner: """Test the inner _search function.""" @pytest.fixture def config(self): return SearchConfig( embed_search_tool="embed_search", agent_mode_llm="gpt-4o", vst_internal_url="http://localhost:30888", ) @pytest.fixture def mock_builder(self): builder = AsyncMock() return builder async def _get_inner_fn(self, config, mock_builder, embed_output): mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() return function_info.single_fn @pytest.mark.asyncio async def test_basic_search_no_agent_mode(self, config, mock_builder): embed_output = _make_embed_output_with_results( [ { "video_name": "camera1.mp4", "description": "Test", "start_time": "2025-01-15T10:00:00Z", "end_time": "2025-01-15T10:30:00Z", "screenshot_url": "http://example.com/screenshot.jpg", "similarity_score": 0.95, } ] ) inner_fn = await self._get_inner_fn(config, mock_builder, embed_output) inp = SearchInput(query="find cars", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert isinstance(result, SearchOutput) assert len(result.data) == 1 assert result.data[0].video_name == "camera1.mp4" assert result.data[0].similarity == 0.95 @pytest.mark.asyncio async def test_search_with_video_sources(self, config, mock_builder): embed_output = _make_embed_output_with_results( [ { "video_name": "cam1.mp4", "similarity_score": 0.8, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", "screenshot_url": "", } ] ) inner_fn = await self._get_inner_fn(config, mock_builder, embed_output) inp = SearchInput( query="find person", source_type="video_file", agent_mode=False, video_sources=["cam1.mp4"], top_k=5, ) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_with_timestamps(self, config, mock_builder): from datetime import UTC from datetime import datetime embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.9, "start_time": "2025-01-15T10:00:00Z", "end_time": "2025-01-15T10:30:00Z", } ] ) inner_fn = await self._get_inner_fn(config, mock_builder, embed_output) inp = SearchInput( query="find car", source_type="video_file", agent_mode=False, timestamp_start=datetime(2025, 1, 15, 10, 0, 0, tzinfo=UTC), timestamp_end=datetime(2025, 1, 15, 11, 0, 0, tzinfo=UTC), description="parking lot", ) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_no_results(self, config, mock_builder): embed_output = EmbedSearchOutput(query_embedding=[], results=[]) inner_fn = await self._get_inner_fn(config, mock_builder, embed_output) inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert isinstance(result, SearchOutput) assert len(result.data) == 0 @pytest.mark.asyncio async def test_search_empty_video_name_skipped(self, config, mock_builder): embed_output = _make_embed_output_with_results( [ { "video_name": "", "similarity_score": 0.9, } ] ) inner_fn = await self._get_inner_fn(config, mock_builder, embed_output) inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert len(result.data) == 0 @pytest.mark.asyncio async def test_search_string_output(self, config, mock_builder): """Test when embed_search returns a JSON string.""" embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.9, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) json_str = embed_output.model_dump_json() mock_embed = AsyncMock() mock_embed.ainvoke.return_value = json_str mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_with_agent_mode(self, config, mock_builder): embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.85, "start_time": "2025-01-01T13:00:00Z", "end_time": "2025-01-01T14:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_response = MagicMock() mock_llm_response.content = json.dumps( { "query": "person pushing cart", "description": "endeavor heart", "timestamp_start": "2025-01-01T13:00:00Z", "timestamp_end": "2025-01-01T14:00:00Z", "top_k": 5, } ) mock_llm.ainvoke.return_value = mock_llm_response mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="person pushing a cart in endeavor heart", source_type="video_file", agent_mode=True) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_agent_mode_json_code_block(self, config, mock_builder): embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.85, "start_time": "2025-01-01T13:00:00Z", "end_time": "2025-01-01T14:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_response = MagicMock() mock_llm_response.content = '```json\n{"query": "test", "video_sources": ["cam1"]}\n```' mock_llm.ainvoke.return_value = mock_llm_response mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test in cam1", source_type="video_file", agent_mode=True) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_agent_mode_code_block_no_json(self, config, mock_builder): embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.8, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_response = MagicMock() mock_llm_response.content = '```\n{"query": "test"}\n```' mock_llm.ainvoke.return_value = mock_llm_response mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_agent_mode_invalid_json(self, config, mock_builder): embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.8, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_response = MagicMock() mock_llm_response.content = "not valid json at all" mock_llm.ainvoke.return_value = mock_llm_response mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_agent_mode_llm_error(self, config, mock_builder): """Test agent_mode when LLM raises error.""" embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.8, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm.ainvoke.side_effect = RuntimeError("LLM error") mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_embed_value_error(self, config, mock_builder): """Test handling ValueError from embed_search.""" from fastapi import HTTPException mock_embed = AsyncMock() mock_embed.ainvoke.side_effect = ValueError("Index not found") mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) with pytest.raises(HTTPException) as exc_info: await inner_fn(inp) assert exc_info.value.status_code == 404 @pytest.mark.asyncio async def test_search_embed_generic_error(self, config, mock_builder): """Test handling generic error from embed_search.""" from fastapi import HTTPException mock_embed = AsyncMock() mock_embed.ainvoke.side_effect = RuntimeError("Something went wrong") mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) with pytest.raises(HTTPException) as exc_info: await inner_fn(inp) assert exc_info.value.status_code == 500 @pytest.mark.asyncio async def test_search_embed_error_with_status_code(self, config, mock_builder): """Test handling error with status_code attribute.""" from fastapi import HTTPException err = RuntimeError("ES error") err.status_code = 503 mock_embed = AsyncMock() mock_embed.ainvoke.side_effect = err mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=False) with pytest.raises(HTTPException) as exc_info: await inner_fn(inp) assert exc_info.value.status_code == 503 @pytest.mark.asyncio async def test_search_with_description_in_results(self, config, mock_builder): """Test that description is passed through from embed results.""" embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "description": "Front entrance", "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", "similarity_score": 0.9, } ] ) inner_fn = await self._get_inner_fn(config, mock_builder, embed_output) inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert result.data[0].description == "Front entrance" @pytest.mark.asyncio async def test_search_with_float_timestamps_in_response(self, config, mock_builder): """Test handling float start_time and end_time in response.""" embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.9, "start_time": "2025-01-01T00:01:40Z", "end_time": "2025-01-01T00:03:20Z", } ] ) inner_fn = await self._get_inner_fn(config, mock_builder, embed_output) inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await inner_fn(inp) assert isinstance(result, SearchOutput) assert len(result.data) == 1 @pytest.mark.asyncio async def test_search_agent_mode_with_min_cosine_similarity(self, config, mock_builder): """Test agent mode extracting min_cosine_similarity.""" embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.9, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_response = MagicMock() mock_llm_response.content = json.dumps( { "query": "test", "min_cosine_similarity": 0.5, "top_k": "invalid", "video_sources": "single_video", } ) mock_llm.ainvoke.return_value = mock_llm_response mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_agent_mode_invalid_timestamps(self, config, mock_builder): """Test agent mode with invalid extracted timestamps.""" embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.9, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_response = MagicMock() mock_llm_response.content = json.dumps( { "query": "test", "timestamp_start": "invalid-date", "timestamp_end": "also-invalid", "min_cosine_similarity": "not-a-number", } ) mock_llm.ainvoke.return_value = mock_llm_response mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_agent_mode_json_block_no_closing(self, config, mock_builder): """Test agent mode with json block without closing markers.""" embed_output = _make_embed_output_with_results( [ { "video_name": "cam.mp4", "similarity_score": 0.9, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_response = MagicMock() mock_llm_response.content = '```json\n{"query": "test"}' mock_llm.ainvoke.return_value = mock_llm_response mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() inner_fn = function_info.single_fn inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await inner_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_search_converters(self, config, mock_builder): """Test that converters are registered.""" mock_embed = AsyncMock() mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) function_info = await gen.__anext__() assert function_info.converters is not None assert len(function_info.converters) >= 4 ================================================ FILE: agent/tests/unit_test/tools/test_search_more_edge_cases.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional edge case tests for search module.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock import pytest from vss_agents.tools.embed_search import EmbedSearchOutput from vss_agents.tools.embed_search import EmbedSearchResultItem from vss_agents.tools.search import SearchConfig from vss_agents.tools.search import SearchInput from vss_agents.tools.search import SearchOutput from vss_agents.tools.search import search def _make_embed_output(results): """Helper to build an EmbedSearchOutput with results.""" items = [] for r in results: items.append( EmbedSearchResultItem( video_name=r.get("video_name", ""), description=r.get("description", ""), start_time=r.get("start_time", ""), end_time=r.get("end_time", ""), sensor_id=r.get("sensor_id", "s1"), screenshot_url=r.get("screenshot_url", ""), similarity_score=float(r.get("similarity_score", 0.0)), ) ) return EmbedSearchOutput(query_embedding=[0.1, 0.2], results=items) class TestSearchMoreEdgeCases: """Cover remaining uncovered lines in search.py.""" @pytest.fixture def config(self): return SearchConfig( embed_search_tool="embed_search", agent_mode_llm="gpt-4o", vst_internal_url="http://localhost:30888" ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_agent_mode_code_block_no_closing(self, config, mock_builder): """Test agent mode with code block without closing backticks.""" embed_output = _make_embed_output( [ { "video_name": "cam.mp4", "similarity_score": 0.9, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_resp = MagicMock() mock_llm_resp.content = '```\n{"query": "test"}' # No closing ``` mock_llm.ainvoke.return_value = mock_llm_resp mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await fi.single_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_query_exception_skipped(self, config, mock_builder): """Test that exceptions in individual query processing are caught.""" # An empty video_name should be skipped embed_output = _make_embed_output( [ { "video_name": "", "similarity_score": 0.9, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await fi.single_fn(inp) assert isinstance(result, SearchOutput) assert len(result.data) == 0 @pytest.mark.asyncio async def test_agent_mode_not_dict_extracted(self, config, mock_builder): """Test agent mode when LLM returns non-dict JSON.""" embed_output = _make_embed_output([]) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() mock_llm_resp = MagicMock() mock_llm_resp.content = '"just a string"' mock_llm.ainvoke.return_value = mock_llm_resp mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await fi.single_fn(inp) assert isinstance(result, SearchOutput) @pytest.mark.asyncio async def test_no_timestamp_no_base(self, config, mock_builder): """Test when no start_time is provided in result.""" embed_output = _make_embed_output( [ { "video_name": "cam.mp4", "similarity_score": 0.9, } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_builder.get_llm.return_value = AsyncMock() gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inp = SearchInput(query="test", source_type="video_file", agent_mode=False) result = await fi.single_fn(inp) assert isinstance(result, SearchOutput) assert len(result.data) == 1 @pytest.mark.asyncio async def test_agent_mode_response_without_content_attr(self, config, mock_builder): """Test agent mode LLM response without content attribute.""" embed_output = _make_embed_output( [ { "video_name": "cam.mp4", "similarity_score": 0.9, "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-01T01:00:00Z", } ] ) mock_embed = AsyncMock() mock_embed.ainvoke.return_value = embed_output mock_builder.get_function.return_value = mock_embed mock_llm = AsyncMock() # LLM response that is just a string (no .content attribute) mock_llm.ainvoke.return_value = '{"query": "test"}' mock_builder.get_llm.return_value = mock_llm gen = search.__wrapped__(config, mock_builder) fi = await gen.__anext__() inp = SearchInput(query="test", source_type="video_file", agent_mode=True) result = await fi.single_fn(inp) assert isinstance(result, SearchOutput) ================================================ FILE: agent/tests/unit_test/tools/test_template_report_gen.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for template_report_gen module.""" from unittest.mock import MagicMock from vss_agents.tools.template_report_gen import PDF_CONVERSION_AVAILABLE from vss_agents.tools.template_report_gen import _get_object_store_url class TestGetObjectStoreUrl: """Test _get_object_store_url function.""" def test_s3_object_store(self): mock_store = MagicMock() mock_store.endpoint_url = "http://minio.example.com:9000" mock_store.bucket_name = "reports" mock_config = MagicMock() mock_config.base_url = "http://localhost:8000" result = _get_object_store_url(mock_store, "report.pdf", mock_config) assert result == "http://minio.example.com:9000/reports/report.pdf" def test_s3_object_store_with_trailing_slash(self): mock_store = MagicMock() mock_store.endpoint_url = "http://minio.example.com:9000/" mock_store.bucket_name = "bucket" mock_config = MagicMock() result = _get_object_store_url(mock_store, "file.pdf", mock_config) assert result == "http://minio.example.com:9000/bucket/file.pdf" def test_in_memory_store(self): mock_store = MagicMock(spec=[]) # No endpoint_url or bucket_name mock_config = MagicMock() mock_config.base_url = "http://localhost:8000/" result = _get_object_store_url(mock_store, "report.pdf", mock_config) assert result == "http://localhost:8000/report.pdf" def test_in_memory_store_base_url_no_trailing_slash(self): mock_store = MagicMock(spec=[]) mock_config = MagicMock() mock_config.base_url = "http://localhost:8000" result = _get_object_store_url(mock_store, "test.pdf", mock_config) assert result == "http://localhost:8000/test.pdf" class TestPdfConversionAvailable: """Test PDF conversion availability flag.""" def test_pdf_conversion_flag_is_bool(self): assert isinstance(PDF_CONVERSION_AVAILABLE, bool) ================================================ FILE: agent/tests/unit_test/tools/test_video_caption.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_caption module.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from pydantic import ValidationError import pytest from vss_agents.tools.video_caption import VLM_PROMPT from vss_agents.tools.video_caption import VideoCaptionConfig from vss_agents.tools.video_caption import VideoCaptionInput from vss_agents.tools.video_caption import call_vlm_partition from vss_agents.tools.video_caption import error_messages class TestVideoCaptionConfig: """Test VideoCaptionConfig model.""" def test_required_fields(self): config = VideoCaptionConfig(llm_name="test_llm") assert config.llm_name == "test_llm" assert config.prompt == VLM_PROMPT assert config.max_retries == 3 assert config.max_frames_per_request == 10 assert config.use_vss is True assert config.vss_backend_url == "http://localhost:31000" def test_custom_values(self): config = VideoCaptionConfig( llm_name="custom_llm", prompt="custom prompt", max_retries=5, max_frames_per_request=20, use_vss=False, vss_backend_url="http://custom:8080", ) assert config.llm_name == "custom_llm" assert config.prompt == "custom prompt" assert config.max_retries == 5 assert config.max_frames_per_request == 20 assert config.use_vss is False class TestVideoCaptionInput: """Test VideoCaptionInput model.""" def test_valid_input(self): input_data = VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe the video", fps=1.0, video_duration=60.0, ) assert input_data.filename == "video.mp4" assert input_data.start_timestamp == 0.0 assert input_data.end_timestamp == 10.0 assert input_data.fps == 1.0 def test_end_timestamp_clamped_to_duration(self): input_data = VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=100.0, # Greater than video_duration user_prompt="Describe the video", video_duration=60.0, ) # Should be clamped to video_duration - 0.01 assert input_data.end_timestamp == 59.99 def test_end_timestamp_none_uses_duration(self): input_data = VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=None, user_prompt="Describe the video", video_duration=60.0, ) assert input_data.end_timestamp == 59.99 def test_negative_duration_raises(self): with pytest.raises(ValidationError): VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe the video", video_duration=-1.0, ) def test_zero_duration_raises(self): with pytest.raises(ValidationError): VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe the video", video_duration=0.0, ) def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe the video", video_duration=60.0, extra_field="not allowed", ) class TestErrorMessages: """Test error_messages constant.""" def test_error_messages_defined(self): assert len(error_messages) > 0 assert "I'm sorry, I can't help with that" in error_messages assert "I'm unable to" in error_messages class TestCallVlmPartition: """Test call_vlm_partition async function.""" @pytest.mark.asyncio async def test_successful_caption(self): mock_llm = AsyncMock() mock_response = MagicMock() mock_response.content = "[10.45] Person walking across the street" mock_llm.ainvoke.return_value = mock_response base64_frames = ["frame1_base64", "frame2_base64"] template_prompt = "Test prompt fps={fps} user_prompt={user_prompt} start_timestamp={start_timestamp}" result = await call_vlm_partition(mock_llm, base64_frames, template_prompt, "describe", 10.0, 1.0, 3) assert result[0] == 10.0 # start_timestamp assert result[1] == "[10.45] Person walking across the street" @pytest.mark.asyncio async def test_retry_on_error_message(self): mock_llm = AsyncMock() # First call returns error, second call succeeds mock_error_response = MagicMock() mock_error_response.content = "I'm sorry, I can't help with that" mock_success_response = MagicMock() mock_success_response.content = "[10.0] Valid caption" mock_retry_prompt_response = MagicMock() mock_retry_prompt_response.content = "Modified prompt" mock_llm.ainvoke.side_effect = [ mock_error_response, mock_retry_prompt_response, mock_success_response, ] base64_frames = ["frame1_base64"] template_prompt = "Test prompt fps={fps} user_prompt={user_prompt} start_timestamp={start_timestamp}" await call_vlm_partition(mock_llm, base64_frames, template_prompt, "describe", 10.0, 1.0, 3) assert mock_llm.ainvoke.call_count >= 2 @pytest.mark.asyncio async def test_success_without_retry(self): mock_llm = AsyncMock() mock_response = MagicMock() mock_response.content = "A detailed description of the video content that is longer than 80 characters so it won't trigger retry logic." mock_llm.ainvoke.return_value = mock_response base64_frames = ["frame1_base64"] template_prompt = "Test prompt fps={fps} user_prompt={user_prompt} start_timestamp={start_timestamp}" result = await call_vlm_partition(mock_llm, base64_frames, template_prompt, "describe", 0.0, 1.0, 3) assert result[0] == 0.0 assert "detailed description" in result[1] assert mock_llm.ainvoke.call_count == 1 class TestVLMPrompt: """Test VLM_PROMPT constant.""" def test_prompt_contains_placeholders(self): assert "{fps}" in VLM_PROMPT assert "{user_prompt}" in VLM_PROMPT assert "{start_timestamp}" in VLM_PROMPT def test_prompt_formatting(self): formatted = VLM_PROMPT.format(fps=1.0, user_prompt="describe the scene", start_timestamp=10.0) assert "1.0" in formatted assert "describe the scene" in formatted assert "10.0" in formatted ================================================ FILE: agent/tests/unit_test/tools/test_video_caption_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for video_caption module to improve coverage.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from pydantic import ValidationError import pytest from vss_agents.tools.video_caption import VideoCaptionConfig from vss_agents.tools.video_caption import VideoCaptionInput from vss_agents.tools.video_caption import call_vlm_partition from vss_agents.tools.video_caption import error_messages class TestVideoCaptionConfig: """Test VideoCaptionConfig model.""" def test_required_fields(self): config = VideoCaptionConfig(llm_name="test-llm") assert config.llm_name == "test-llm" assert config.max_retries == 3 assert config.max_frames_per_request == 10 assert config.use_vss is True def test_custom_fields(self): config = VideoCaptionConfig( llm_name="custom-llm", prompt="Custom prompt {fps} {user_prompt} {start_timestamp}", max_retries=5, max_frames_per_request=20, use_vss=False, vss_backend_url="http://custom:9000", ) assert config.max_retries == 5 assert config.use_vss is False assert config.vss_backend_url == "http://custom:9000" class TestVideoCaptionInput: """Test VideoCaptionInput model.""" def test_valid_input(self): inp = VideoCaptionInput( filename="video.mp4", start_timestamp=10.0, end_timestamp=20.0, user_prompt="Describe the scene", fps=1.0, video_duration=100.0, ) assert inp.filename == "video.mp4" assert inp.start_timestamp == 10.0 assert inp.end_timestamp == 20.0 def test_end_timestamp_capped_to_duration(self): inp = VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=200.0, user_prompt="test", fps=1.0, video_duration=100.0, ) assert inp.end_timestamp == pytest.approx(99.99) def test_end_timestamp_none_capped(self): inp = VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=None, user_prompt="test", fps=1.0, video_duration=50.0, ) assert inp.end_timestamp == pytest.approx(49.99) def test_zero_duration_raises(self): with pytest.raises(ValueError, match="Video duration must be positive"): VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="test", fps=1.0, video_duration=0.0, ) def test_negative_duration_raises(self): with pytest.raises(ValueError, match="Video duration must be positive"): VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="test", fps=1.0, video_duration=-5.0, ) def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="test", fps=1.0, video_duration=100.0, extra_field="not allowed", ) class TestErrorMessages: """Test error_messages list.""" def test_error_messages_exist(self): assert len(error_messages) > 0 assert any("I'm sorry" in msg for msg in error_messages) assert any("I'm unable" in msg for msg in error_messages) class TestCallVlmPartition: """Test call_vlm_partition function.""" @pytest.mark.asyncio async def test_successful_caption(self): mock_llm = AsyncMock() mock_response = MagicMock() mock_response.content = "[10.45] A person walks." mock_llm.ainvoke.return_value = mock_response start_ts, _caption = await call_vlm_partition( llm=mock_llm, base64_frames=["base64data1", "base64data2"], template_prompt="Describe video at fps {fps}. Query: {user_prompt}. Start: {start_timestamp}.", user_prompt="find person", start_timestamp=10.0, fps=1.0, max_retries=3, ) assert start_ts == 10.0 assert "person" in _caption @pytest.mark.asyncio async def test_retry_on_error_message(self): mock_llm = AsyncMock() error_response = MagicMock() error_response.content = "I'm sorry, I can't help with that" modified_prompt_response = MagicMock() modified_prompt_response.content = "Modified prompt text" success_response = MagicMock() success_response.content = "[10.0] Scene description" mock_llm.ainvoke.side_effect = [ error_response, modified_prompt_response, success_response, ] start_ts, _caption = await call_vlm_partition( llm=mock_llm, base64_frames=["frame1"], template_prompt="fps {fps} query {user_prompt} start {start_timestamp}", user_prompt="test", start_timestamp=10.0, fps=1.0, max_retries=3, ) assert start_ts == 10.0 assert "Scene description" in _caption @pytest.mark.asyncio async def test_no_retry_for_long_error_message(self): """Long error messages should not trigger retry.""" mock_llm = AsyncMock() long_response = MagicMock() long_response.content = "I'm sorry, I can't help with that" + " but here is a very long explanation " * 5 mock_llm.ainvoke.return_value = long_response start_ts, _caption = await call_vlm_partition( llm=mock_llm, base64_frames=["frame1"], template_prompt="fps {fps} query {user_prompt} start {start_timestamp}", user_prompt="test", start_timestamp=5.0, fps=1.0, max_retries=1, ) assert start_ts == 5.0 # Should return after first call since message is long assert mock_llm.ainvoke.call_count == 1 ================================================ FILE: agent/tests/unit_test/tools/test_video_caption_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for video_detailed_caption and video_skim_caption inner functions.""" from unittest.mock import AsyncMock import pytest from vss_agents.tools.video_detailed_caption import VideoDetailedCaptionConfig from vss_agents.tools.video_detailed_caption import VideoDetailedCaptionInput from vss_agents.tools.video_detailed_caption import video_detailed_caption from vss_agents.tools.video_skim_caption import VideoSkimCaptionConfig from vss_agents.tools.video_skim_caption import VideoSkimCaptionInput from vss_agents.tools.video_skim_caption import video_skim_caption class TestVideoDetailedCaptionInner: """Test video_detailed_caption inner function.""" @pytest.fixture def config(self): return VideoDetailedCaptionConfig(detailed_fps=2.0, max_video_duration=60) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_caption_success(self, config, mock_builder): mock_tool = AsyncMock() mock_tool.ainvoke.return_value = "Caption: person walking" mock_builder.get_tool.return_value = mock_tool gen = video_detailed_caption.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=10.0, end_timestamp=20.0, user_prompt="Describe the scene", video_duration=100.0, ) result = await inner_fn(inp) assert "person walking" in result @pytest.mark.asyncio async def test_duration_too_long(self, config, mock_builder): gen = video_detailed_caption.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=80.0, # > max_video_duration of 60 user_prompt="Describe", video_duration=100.0, ) result = await inner_fn(inp) assert "too long" in result.lower() @pytest.mark.asyncio async def test_caption_tool_error(self, config, mock_builder): mock_tool = AsyncMock() mock_tool.ainvoke.side_effect = RuntimeError("VLM error") mock_builder.get_tool.return_value = mock_tool gen = video_detailed_caption.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=10.0, end_timestamp=20.0, user_prompt="Describe", video_duration=100.0, ) with pytest.raises(RuntimeError, match="VLM error"): await inner_fn(inp) class TestVideoSkimCaptionInner: """Test video_skim_caption inner function.""" @pytest.fixture def config(self): return VideoSkimCaptionConfig(skim_fps=0.5) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_skim_success(self, config, mock_builder): mock_tool = AsyncMock() mock_tool.ainvoke.return_value = "Skim: parking lot overview" mock_builder.get_tool.return_value = mock_tool gen = video_skim_caption.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VideoSkimCaptionInput( filename="long_video.mp4", start_timestamp=0.0, end_timestamp=300.0, user_prompt="Summarize", video_duration=600.0, ) result = await inner_fn(inp) assert "parking lot" in result @pytest.mark.asyncio async def test_skim_tool_error(self, config, mock_builder): mock_tool = AsyncMock() mock_tool.ainvoke.side_effect = RuntimeError("Skim error") mock_builder.get_tool.return_value = mock_tool gen = video_skim_caption.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=100.0, user_prompt="Summarize", video_duration=200.0, ) with pytest.raises(RuntimeError, match="Skim error"): await inner_fn(inp) ================================================ FILE: agent/tests/unit_test/tools/test_video_caption_vss_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for video_caption inner function (VSS path) via generator invocation.""" import os import shutil from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.tools.video_caption import VideoCaptionConfig from vss_agents.tools.video_caption import VideoCaptionInput from vss_agents.tools.video_caption import video_caption class TestVideoCaptionVSSInner: """Test the VSS path of video_caption.""" @pytest.fixture def config_vss(self): return VideoCaptionConfig( llm_name="test-llm", use_vss=True, vss_backend_url="http://vss:31000", ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_vss_caption_success(self, config_vss, mock_builder): # Mock vst_download_tool (not available) mock_builder.get_tool.side_effect = [ RuntimeError("not available"), # vst_download ] # Set up tools for the VSS path mock_summarize = AsyncMock() mock_summarize_output = MagicMock() mock_summarize_output.summary = "A person walks through parking lot" mock_summarize.ainvoke.return_value = mock_summarize_output mock_upload = AsyncMock() mock_upload_output = MagicMock() mock_upload_output.file_id = "550e8400-e29b-41d4-a716-446655440000" mock_upload.ainvoke.return_value = mock_upload_output # Reconfigure builder.get_tool to return tools for vss path call_count = [0] async def get_tool_side_effect(name, **kwargs): call_count[0] += 1 if call_count[0] == 1: raise RuntimeError("vst_download not available") return None mock_builder.get_tool = AsyncMock(side_effect=get_tool_side_effect) # We need to properly mock the builder to get vss_summarize_tool and vss_file_upload_tool # The config references them by name, and builder.get_tool is called for each # Reset mock_builder setup mock_builder_fresh = AsyncMock() mock_builder_fresh.get_tool = AsyncMock( side_effect=[ RuntimeError("vst_download not available"), mock_summarize, # vss_summarize_tool mock_upload, # vss_file_upload_tool ] ) # Mock resolve_video_file with patch("vss_agents.tools.video_caption.resolve_video_file", new_callable=AsyncMock) as mock_resolve: mock_resolve.return_value = ("/tmp/test_video.mp4", False) with patch("vss_agents.tools.video_caption.httpx.AsyncClient") as mock_httpx: mock_client = AsyncMock() mock_httpx.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_httpx.return_value.__aexit__ = AsyncMock(return_value=False) gen = video_caption.__wrapped__(config_vss, mock_builder_fresh) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VideoCaptionInput( filename="video.mp4", start_timestamp=10.0, end_timestamp=20.0, user_prompt="Describe", fps=1.0, video_duration=100.0, ) result = await inner_fn(inp) assert "person" in result.lower() or "Video captions" in result @pytest.mark.asyncio async def test_vss_caption_with_cleanup(self, config_vss, mock_builder): mock_summarize = AsyncMock() mock_summarize_output = MagicMock() mock_summarize_output.summary = "Test summary" mock_summarize.ainvoke.return_value = mock_summarize_output mock_upload = AsyncMock() mock_upload_output = MagicMock() mock_upload_output.file_id = "550e8400-e29b-41d4-a716-446655440000" mock_upload.ainvoke.return_value = mock_upload_output mock_builder_fresh = AsyncMock() mock_builder_fresh.get_tool = AsyncMock( side_effect=[ RuntimeError("no vst_download"), mock_summarize, mock_upload, ] ) # Create a temp dir for cleanup test temp_dir = "/tmp/test_vss_cleanup_dir" os.makedirs(temp_dir, exist_ok=True) temp_file = os.path.join(temp_dir, "clip.mp4") with open(temp_file, "w") as f: f.write("fake video data") with patch("vss_agents.tools.video_caption.resolve_video_file", new_callable=AsyncMock) as mock_resolve: mock_resolve.return_value = (temp_file, True) # needs_cleanup=True with patch("vss_agents.tools.video_caption.httpx.AsyncClient") as mock_httpx: mock_client = AsyncMock() mock_httpx.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_httpx.return_value.__aexit__ = AsyncMock(return_value=False) gen = video_caption.__wrapped__(config_vss, mock_builder_fresh) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VideoCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="test", fps=1.0, video_duration=30.0, ) result = await inner_fn(inp) assert isinstance(result, str) # Cleanup should have been triggered if os.path.exists(temp_dir): shutil.rmtree(temp_dir) class TestVideoCaptionNonVSSInner: """Test the non-VSS (direct VLM) path of video_caption.""" @pytest.fixture def config_no_vss(self): return VideoCaptionConfig( llm_name="test-llm", use_vss=False, max_retries=1, max_frames_per_request=5, ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_non_vss_caption(self, config_no_vss, mock_builder): mock_llm = AsyncMock() mock_response = MagicMock() mock_response.content = "[10.0] A person walks through a parking lot" mock_llm.ainvoke.return_value = mock_response mock_builder.get_llm.return_value = mock_llm # Get vst_download_tool raises (not available) mock_builder.get_tool.side_effect = RuntimeError("not available") mock_frames = ["base64frame1", "base64frame2"] with patch("vss_agents.tools.video_caption.resolve_video_file", new_callable=AsyncMock) as mock_resolve: mock_resolve.return_value = ("/tmp/test_vid.mp4", False) with patch("vss_agents.utils.frame_select.frame_select", return_value=mock_frames): gen = video_caption.__wrapped__(config_no_vss, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VideoCaptionInput( filename="video.mp4", start_timestamp=10.0, end_timestamp=12.0, user_prompt="Describe", fps=1.0, video_duration=100.0, ) result = await inner_fn(inp) assert "person" in result.lower() or "Video captions" in result ================================================ FILE: agent/tests/unit_test/tools/test_video_detailed_caption.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_detailed_caption module.""" from pydantic import ValidationError import pytest from vss_agents.tools.video_detailed_caption import VideoDetailedCaptionConfig from vss_agents.tools.video_detailed_caption import VideoDetailedCaptionInput class TestVideoDetailedCaptionConfig: """Test VideoDetailedCaptionConfig model.""" def test_defaults(self): config = VideoDetailedCaptionConfig() assert config.detailed_fps == 2.0 assert config.max_video_duration == 60 def test_custom_values(self): config = VideoDetailedCaptionConfig( detailed_fps=4.0, max_video_duration=120, ) assert config.detailed_fps == 4.0 assert config.max_video_duration == 120 class TestVideoDetailedCaptionInput: """Test VideoDetailedCaptionInput model.""" def test_valid_input(self): input_data = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=30.0, user_prompt="Describe in detail", video_duration=60.0, ) assert input_data.filename == "video.mp4" assert input_data.start_timestamp == 0.0 assert input_data.end_timestamp == 30.0 assert input_data.user_prompt == "Describe in detail" assert input_data.video_duration == 60.0 def test_end_timestamp_clamped_to_duration(self): input_data = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=100.0, # Greater than video_duration user_prompt="Describe in detail", video_duration=60.0, ) # Should be clamped to video_duration - 0.01 assert input_data.end_timestamp == 59.99 def test_end_timestamp_none_uses_duration(self): input_data = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=None, user_prompt="Describe in detail", video_duration=60.0, ) assert input_data.end_timestamp == 59.99 def test_negative_duration_raises(self): with pytest.raises(ValidationError): VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe", video_duration=-1.0, ) def test_zero_duration_raises(self): with pytest.raises(ValidationError): VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe", video_duration=0.0, ) def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe", video_duration=60.0, extra_field="not allowed", ) def test_missing_filename_raises(self): with pytest.raises(ValidationError): VideoDetailedCaptionInput( start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe", video_duration=60.0, ) def test_missing_start_timestamp_raises(self): with pytest.raises(ValidationError): VideoDetailedCaptionInput( filename="video.mp4", end_timestamp=10.0, user_prompt="Describe", video_duration=60.0, ) def test_missing_user_prompt_raises(self): with pytest.raises(ValidationError): VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, video_duration=60.0, ) def test_missing_video_duration_raises(self): # Missing video_duration triggers KeyError in model_validator before field validation with pytest.raises(KeyError): VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="Describe", ) ================================================ FILE: agent/tests/unit_test/tools/test_video_detailed_caption_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for video_detailed_caption module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.video_detailed_caption import VideoDetailedCaptionConfig from vss_agents.tools.video_detailed_caption import VideoDetailedCaptionInput class TestVideoDetailedCaptionConfig: """Test VideoDetailedCaptionConfig model.""" def test_defaults(self): config = VideoDetailedCaptionConfig() assert config.detailed_fps == 2.0 assert config.max_video_duration == 60 def test_custom(self): config = VideoDetailedCaptionConfig( detailed_fps=4.0, max_video_duration=120, ) assert config.detailed_fps == 4.0 assert config.max_video_duration == 120 class TestVideoDetailedCaptionInput: """Test VideoDetailedCaptionInput model.""" def test_valid_input(self): inp = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=10.0, end_timestamp=20.0, user_prompt="Describe what happens", video_duration=100.0, ) assert inp.filename == "video.mp4" assert inp.start_timestamp == 10.0 assert inp.end_timestamp == 20.0 def test_end_timestamp_capped(self): inp = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=200.0, user_prompt="test", video_duration=50.0, ) assert inp.end_timestamp == pytest.approx(49.99) def test_end_timestamp_none(self): inp = VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=None, user_prompt="test", video_duration=30.0, ) assert inp.end_timestamp == pytest.approx(29.99) def test_zero_duration_raises(self): with pytest.raises(ValueError, match="Video duration must be positive"): VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="test", video_duration=0.0, ) def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): VideoDetailedCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="test", video_duration=100.0, extra="not_allowed", ) ================================================ FILE: agent/tests/unit_test/tools/test_video_frame_timestamp.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_frame_timestamp module.""" from pydantic import ValidationError import pytest from vss_agents.prompt import VIDEO_FRAME_TIMESTAMP_PROMPT from vss_agents.tools.video_frame_timestamp import VideoFrameTimestampConfig from vss_agents.tools.video_frame_timestamp import VideoFrameTimestampInput class TestVideoFrameTimestampConfig: """Test VideoFrameTimestampConfig model.""" def test_defaults(self): config = VideoFrameTimestampConfig() assert config.llm_name == "openai_llm" assert config.prompt == VIDEO_FRAME_TIMESTAMP_PROMPT def test_custom_values(self): config = VideoFrameTimestampConfig( llm_name="custom_llm", prompt="Custom prompt for timestamp extraction", ) assert config.llm_name == "custom_llm" assert config.prompt == "Custom prompt for timestamp extraction" class TestVideoFrameTimestampInput: """Test VideoFrameTimestampInput model.""" def test_valid_input(self): input_data = VideoFrameTimestampInput( asset_file_path="/path/to/video.mp4", frame_offset_seconds=10.5, ) assert input_data.asset_file_path == "/path/to/video.mp4" assert input_data.frame_offset_seconds == 10.5 def test_zero_offset(self): input_data = VideoFrameTimestampInput( asset_file_path="/path/to/video.mp4", frame_offset_seconds=0.0, ) assert input_data.frame_offset_seconds == 0.0 def test_large_offset(self): input_data = VideoFrameTimestampInput( asset_file_path="/path/to/video.mp4", frame_offset_seconds=3600.0, # 1 hour ) assert input_data.frame_offset_seconds == 3600.0 def test_missing_asset_file_path_raises(self): with pytest.raises(ValidationError): VideoFrameTimestampInput( frame_offset_seconds=10.0, ) def test_missing_frame_offset_raises(self): with pytest.raises(ValidationError): VideoFrameTimestampInput( asset_file_path="/path/to/video.mp4", ) def test_negative_offset_allowed(self): # Negative offset might be valid in some cases input_data = VideoFrameTimestampInput( asset_file_path="/path/to/video.mp4", frame_offset_seconds=-5.0, ) assert input_data.frame_offset_seconds == -5.0 class TestVideoFrameTimestampPrompt: """Test VIDEO_FRAME_TIMESTAMP_PROMPT usage.""" def test_prompt_is_string(self): assert isinstance(VIDEO_FRAME_TIMESTAMP_PROMPT, str) def test_prompt_not_empty(self): assert len(VIDEO_FRAME_TIMESTAMP_PROMPT) > 0 ================================================ FILE: agent/tests/unit_test/tools/test_video_frame_timestamp_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for video_frame_timestamp module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.video_frame_timestamp import VideoFrameTimestampConfig from vss_agents.tools.video_frame_timestamp import VideoFrameTimestampInput class TestVideoFrameTimestampConfig: """Test VideoFrameTimestampConfig model.""" def test_defaults(self): config = VideoFrameTimestampConfig() assert config.llm_name == "openai_llm" assert config.prompt is not None def test_custom(self): config = VideoFrameTimestampConfig( llm_name="custom_llm", prompt="Custom prompt for timestamp extraction", ) assert config.llm_name == "custom_llm" class TestVideoFrameTimestampInput: """Test VideoFrameTimestampInput model.""" def test_valid_input(self): inp = VideoFrameTimestampInput( asset_file_path="/path/to/video.mp4", frame_offset_seconds=30.0, ) assert inp.asset_file_path == "/path/to/video.mp4" assert inp.frame_offset_seconds == 30.0 def test_missing_path_raises(self): with pytest.raises(ValidationError): VideoFrameTimestampInput(frame_offset_seconds=10.0) def test_missing_offset_raises(self): with pytest.raises(ValidationError): VideoFrameTimestampInput(asset_file_path="/path") ================================================ FILE: agent/tests/unit_test/tools/test_video_report_gen.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_report_gen module.""" import tempfile from pydantic import ValidationError import pytest from vss_agents.tools.video_report_gen import TimestampMatch from vss_agents.tools.video_report_gen import VideoReportGenInput from vss_agents.tools.video_report_gen import VideoReportGenOutput from vss_agents.tools.video_report_gen import _convert_markdown_to_pdf from vss_agents.tools.video_report_gen import _divide_video_into_chunks from vss_agents.tools.video_report_gen import _normalize_chunk_timestamps from vss_agents.tools.video_report_gen import _parse_timestamps from vss_agents.tools.video_understanding import VideoUnderstandingInput from vss_agents.tools.video_understanding import VideoUnderstandingOffsetInput class TestTimestampMatch: """Test TimestampMatch NamedTuple.""" def test_creation(self): ts = TimestampMatch(position=10, seconds=5.5) assert ts.position == 10 assert ts.seconds == 5.5 def test_named_access(self): ts = TimestampMatch(position=0, seconds=30.0) assert ts.position == 0 assert ts.seconds == 30.0 def test_tuple_unpacking(self): ts = TimestampMatch(position=100, seconds=45.5) position, seconds = ts assert position == 100 assert seconds == 45.5 class TestParseTimestamps: """Test _parse_timestamps function.""" def test_parse_simple_timestamp(self): content = "Event at [5.0s-10.0s] description." matches = _parse_timestamps(content) assert len(matches) == 1 assert matches[0].seconds == 7.5 # midpoint def test_parse_multiple_timestamps(self): content = "[0.0s-5.0s] First event. [10.0s-20.0s] Second event." matches = _parse_timestamps(content) assert len(matches) == 2 assert matches[0].seconds == 2.5 # midpoint of 0-5 assert matches[1].seconds == 15.0 # midpoint of 10-20 def test_parse_with_spaces(self): content = "Event at [5.0s - 10.0s] with spaces." matches = _parse_timestamps(content) assert len(matches) == 1 assert matches[0].seconds == 7.5 def test_parse_decimal_timestamps(self): content = "Event at [1.5s-3.5s] description." matches = _parse_timestamps(content) assert len(matches) == 1 assert matches[0].seconds == 2.5 # midpoint of 1.5-3.5 def test_parse_no_timestamps(self): content = "No timestamps in this content." matches = _parse_timestamps(content) assert len(matches) == 0 def test_parse_preserves_position(self): content = "Some text [5.0s-10.0s] more text." matches = _parse_timestamps(content) assert len(matches) == 1 assert matches[0].position == 10 # position of '[' def test_parse_large_timestamps(self): content = "[120.0s-180.0s] Event in the middle of a long video." matches = _parse_timestamps(content) assert len(matches) == 1 assert matches[0].seconds == 150.0 # midpoint of 120-180 class TestNormalizeChunkTimestamps: """Test _normalize_chunk_timestamps function.""" def test_timestamps_match_chunk_duration(self): """Timestamps matching chunk duration should just get offset added.""" # Chunk is 60s (60-120), timestamps end at 60s (ratio = 1.0) content = "Event at [30.0s-60.0s] description." result = _normalize_chunk_timestamps(content, chunk_start=60.0, chunk_end=120.0) # No scaling needed, just add offset: 30+60=90, 60+60=120 assert "[90.0s-120.0s]" in result def test_normalization_ratio_scaling_down(self): """Timestamps exceeding chunk duration should be scaled down.""" # Chunk is 60s (60-120), but timestamps go to 90s # ratio = 90/60 = 1.5, so 90s becomes 60s, 45s becomes 30s content = "Event at [45.0s-90.0s] description." result = _normalize_chunk_timestamps(content, chunk_start=60.0, chunk_end=120.0) # After scaling: 45/1.5=30, 90/1.5=60, then add offset 60: 90s-120s assert "[90.0s-120.0s]" in result def test_normalization_ratio_scaling_up(self): """Timestamps much smaller than chunk duration should be scaled up.""" # Chunk is 60s (0-60), but max timestamp is only 30s # ratio = 30/60 = 0.5, so 15s becomes 30s, 30s becomes 60s content = "Event at [15.0s-30.0s] description." result = _normalize_chunk_timestamps(content, chunk_start=0.0, chunk_end=60.0) # After scaling: 15/0.5=30, 30/0.5=60, then add offset 0: 30s-60s assert "[15.0s-30.0s]" in result def test_multiple_timestamps_normalized(self): """Multiple timestamps should all be normalized with same ratio.""" # Chunk is 60s, max timestamp is 90s, ratio = 1.5 content = "[30.0s-45.0s] First. [60.0s-90.0s] Second." result = _normalize_chunk_timestamps(content, chunk_start=0.0, chunk_end=60.0) # 30/1.5=20, 45/1.5=30 -> [20.0s-30.0s] # 60/1.5=40, 90/1.5=60 -> [40.0s-60.0s] assert "[20.0s-30.0s]" in result assert "[40.0s-60.0s]" in result def test_no_timestamps_returns_original(self): """Content without timestamps should return unchanged.""" content = "No timestamps here." result = _normalize_chunk_timestamps(content, chunk_start=60.0, chunk_end=120.0) assert result == content def test_ratio_close_to_one_no_normalization(self): """Ratio within 1% of 1.0 should not trigger normalization.""" # Chunk is 60s, max timestamp is 60.5s, ratio ≈ 1.008 content = "Event at [30.0s-60.5s] description." result = _normalize_chunk_timestamps(content, chunk_start=0.0, chunk_end=60.0) # Should just add offset without scaling assert "[29.8s-60.0s]" in result def test_chunk_offset_applied_with_matching_duration(self): """Chunk start offset should be added when timestamps match duration.""" # Chunk is 60s (120-180), timestamps end at 60s (ratio = 1.0) content = "[0.0s-60.0s] Event spanning full chunk." result = _normalize_chunk_timestamps(content, chunk_start=120.0, chunk_end=180.0) # No scaling, just add offset: 0+120=120, 60+120=180 assert "[120.0s-180.0s]" in result def test_small_timestamps_scaled_up_with_offset(self): """Small timestamps should be scaled up and offset applied.""" # Chunk is 60s (120-180), max timestamp is 10s, ratio = 10/60 = 0.167 content = "[0.0s-10.0s] Event at start." result = _normalize_chunk_timestamps(content, chunk_start=120.0, chunk_end=180.0) # After scaling: 0/0.167=0, 10/0.167=60, then add offset 120: 120s-180s assert "[120.0s-130.0s]" in result class TestDivideVideoIntoChunks: """Test _divide_video_into_chunks function.""" def test_single_chunk(self): """Short video should result in single chunk.""" chunks = _divide_video_into_chunks(30.0, 60.0) assert len(chunks) == 1 assert chunks[0] == (0.0, 30.0) def test_exact_division(self): """Video exactly divisible by chunk size.""" chunks = _divide_video_into_chunks(120.0, 60.0) assert len(chunks) == 2 assert chunks[0] == (0.0, 60.0) assert chunks[1] == (60.0, 120.0) def test_with_remainder(self): """Video with remainder chunk.""" chunks = _divide_video_into_chunks(150.0, 60.0) assert len(chunks) == 3 assert chunks[0] == (0.0, 60.0) assert chunks[1] == (60.0, 120.0) assert chunks[2] == (120.0, 150.0) def test_zero_duration(self): """Zero duration should return empty list.""" chunks = _divide_video_into_chunks(0.0, 60.0) assert len(chunks) == 0 class TestVideoReportGenInput: """Test VideoReportGenInput model.""" def test_required_fields(self): """Test input with required fields only.""" input_data = VideoReportGenInput( sensor_id="sensor-001", user_query="Describe what happens in this video", ) assert input_data.sensor_id == "sensor-001" assert input_data.user_query == "Describe what happens in this video" def test_optional_vlm_reasoning(self): """Test input with optional vlm_reasoning.""" input_data = VideoReportGenInput( sensor_id="sensor-001", user_query="Analyze this video", vlm_reasoning=True, ) assert input_data.vlm_reasoning is True def test_missing_required_fails(self): """Test that missing required fields raises error.""" with pytest.raises(ValidationError): VideoReportGenInput(sensor_id="sensor-001") with pytest.raises(ValidationError): VideoReportGenInput(user_query="Query only") class TestVideoReportGenOutput: """Test VideoReportGenOutput model.""" def test_output_creation(self): """Test output creation with all fields.""" output = VideoReportGenOutput( http_url="http://localhost/report.md", pdf_url="http://localhost/report.pdf", object_store_key="reports/report.md", file_size=1024, pdf_file_size=2048, summary="Report summary", content="# Report\n\nContent here.", ) assert output.http_url == "http://localhost/report.md" assert output.pdf_url == "http://localhost/report.pdf" assert output.object_store_key == "reports/report.md" assert output.file_size == 1024 assert output.pdf_file_size == 2048 assert output.summary == "Report summary" assert output.content == "# Report\n\nContent here." def test_output_optional_fields(self): """Test output with optional fields as None.""" output = VideoReportGenOutput( http_url="http://localhost/report.md", pdf_url=None, object_store_key="reports/report.md", file_size=1024, pdf_file_size=0, summary="Report summary", content="# Report", video_url=None, ) assert output.pdf_url is None assert output.video_url is None def test_output_serialization(self): """Test output serialization.""" output = VideoReportGenOutput( http_url="http://localhost/report.md", pdf_url="http://localhost/report.pdf", object_store_key="reports/report.md", file_size=1024, pdf_file_size=2048, summary="Report summary", content="# Report", ) data = output.model_dump() assert "http_url" in data assert "pdf_url" in data assert "object_store_key" in data assert "file_size" in data assert "pdf_file_size" in data assert "summary" in data assert "content" in data class TestTimestampFormatDetection: """Test that video_report_gen correctly detects the timestamp format expected by the video understanding tool (float offsets vs ISO strings). Regression test for: VideoUnderstandingOffsetInput (stream_mode=false) expects float offsets, but video_report_gen was always passing ISO strings, causing 'could not convert string to float' validation errors. """ def test_non_stream_model_has_float_timestamp(self): """VideoUnderstandingOffsetInput.start_timestamp should be float | None.""" ts_field = VideoUnderstandingOffsetInput.model_fields["start_timestamp"] field_type = ts_field.annotation # float | None resolves to Union[float, NoneType] with __args__ assert hasattr(field_type, "__args__"), "Expected Union type (float | None)" assert float in field_type.__args__, "Expected float in Union args" def test_stream_model_has_str_timestamp(self): """VideoUnderstandingInput.start_timestamp should be str (ISO 8601).""" ts_field = VideoUnderstandingInput.model_fields["start_timestamp"] assert ts_field.annotation is str, "Expected str type for stream mode timestamps" def test_detection_logic_identifies_float_schema(self): """The schema-based detection logic should identify float timestamps.""" schema = VideoUnderstandingOffsetInput ts_field = schema.model_fields.get("start_timestamp") field_type = ts_field.annotation uses_float = field_type is float or (hasattr(field_type, "__args__") and float in field_type.__args__) assert uses_float is True, "Should detect float timestamps for non-stream model" def test_detection_logic_identifies_str_schema(self): """The schema-based detection logic should identify string timestamps.""" schema = VideoUnderstandingInput ts_field = schema.model_fields.get("start_timestamp") field_type = ts_field.annotation uses_float = field_type is float or (hasattr(field_type, "__args__") and float in field_type.__args__) assert uses_float is False, "Should not detect float timestamps for stream model" def test_non_stream_model_accepts_float_offsets(self): """VideoUnderstandingOffsetInput should accept float offsets.""" data = { "sensor_id": "test_video", "start_timestamp": 0.0, "end_timestamp": 25.0, "user_prompt": "Describe the video", } model = VideoUnderstandingOffsetInput.model_validate(data) assert model.start_timestamp == 0.0 assert model.end_timestamp == 25.0 def test_non_stream_model_rejects_iso_timestamps(self): """VideoUnderstandingOffsetInput should reject ISO timestamp strings. This is the exact regression: passing '2025-01-01T00:00:00Z' to a model that expects float offsets caused a validation error in dev-profile-base. """ data = { "sensor_id": "test_video", "start_timestamp": "2025-01-01T00:00:00Z", "end_timestamp": "2025-01-01T00:00:25Z", "user_prompt": "Describe the video", } with pytest.raises(ValidationError, match="could not convert string to float"): VideoUnderstandingOffsetInput.model_validate(data) def test_stream_model_accepts_iso_timestamps(self): """VideoUnderstandingInput should accept ISO timestamp strings.""" data = { "sensor_id": "test_video", "start_timestamp": "2025-01-01T00:00:00Z", "end_timestamp": "2025-01-01T00:00:25Z", "user_prompt": "Describe the video", } model = VideoUnderstandingInput.model_validate(data) assert model.start_timestamp == "2025-01-01T00:00:00Z" assert model.end_timestamp == "2025-01-01T00:00:25Z" class TestResourcesSectionFormatting: """Test that the Resources section in reports is formatted correctly for PDF rendering. Regression test for: 'Video Playback:' label and URL appeared on the same line with text-align:justify, causing a large gap between words in the PDF output. """ def test_video_playback_url_on_separate_paragraph(self): """Video URL should be in a separate paragraph from the label.""" video_url = "http://example.com/video.mp4" markdown_content = "## Analysis\n\nSome content." # Simulate what video_report_gen does markdown_content += "\n\n## Resources\n\n" markdown_content += f"**Video Playback:**\n\n{video_url}\n\n" lines = markdown_content.split("\n") # Find the "Video Playback:" line playback_line_idx = None for i, line in enumerate(lines): if "**Video Playback:**" in line: playback_line_idx = i break assert playback_line_idx is not None, "Should find 'Video Playback:' label" # The URL should NOT be on the same line as the label assert video_url not in lines[playback_line_idx], ( "URL should not be on the same line as 'Video Playback:' label" ) # There should be a blank line between label and URL assert lines[playback_line_idx + 1].strip() == "", "There should be a blank line between the label and the URL" # URL should be on a subsequent line assert any(video_url in line for line in lines[playback_line_idx + 1 :]), "URL should appear after the label" def test_pdf_css_has_word_break_for_links(self): """PDF CSS should include word-break rules for tags to handle long URLs.""" import os # Create a simple markdown file and check the generated HTML contains word-break md_content = "## Resources\n\n**Video Playback:**\n\nhttp://example.com/video.mp4\n" with tempfile.TemporaryDirectory() as tmpdir: md_path = os.path.join(tmpdir, "test.md") pdf_path = os.path.join(tmpdir, "test.pdf") with open(md_path, "w") as f: f.write(md_content) result = _convert_markdown_to_pdf(md_path, pdf_path) if result: # PDF was generated successfully - the CSS is valid assert os.path.exists(pdf_path), "PDF file should be created" assert os.path.getsize(pdf_path) > 0, "PDF file should not be empty" ================================================ FILE: agent/tests/unit_test/tools/test_video_skim_caption.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_skim_caption module.""" from pydantic import ValidationError import pytest from vss_agents.tools.video_skim_caption import VideoSkimCaptionConfig from vss_agents.tools.video_skim_caption import VideoSkimCaptionInput class TestVideoSkimCaptionConfig: """Test VideoSkimCaptionConfig model.""" def test_defaults(self): config = VideoSkimCaptionConfig() assert config.skim_fps == 0.5 def test_custom_values(self): config = VideoSkimCaptionConfig(skim_fps=0.25) assert config.skim_fps == 0.25 class TestVideoSkimCaptionInput: """Test VideoSkimCaptionInput model.""" def test_valid_input(self): input_data = VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=300.0, user_prompt="Skim and describe", video_duration=600.0, ) assert input_data.filename == "video.mp4" assert input_data.start_timestamp == 0.0 assert input_data.end_timestamp == 300.0 assert input_data.user_prompt == "Skim and describe" assert input_data.video_duration == 600.0 def test_end_timestamp_clamped_to_duration(self): input_data = VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=1000.0, # Greater than video_duration user_prompt="Skim and describe", video_duration=600.0, ) # Should be clamped to video_duration - 0.01 assert input_data.end_timestamp == 599.99 def test_end_timestamp_none_uses_duration(self): input_data = VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=None, user_prompt="Skim and describe", video_duration=600.0, ) assert input_data.end_timestamp == 599.99 def test_negative_duration_raises(self): with pytest.raises(ValidationError): VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=100.0, user_prompt="Describe", video_duration=-1.0, ) def test_zero_duration_raises(self): with pytest.raises(ValidationError): VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=100.0, user_prompt="Describe", video_duration=0.0, ) def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=100.0, user_prompt="Describe", video_duration=600.0, extra_field="not allowed", ) def test_long_video_input(self): # Testing with a very long video input_data = VideoSkimCaptionInput( filename="long_video.mp4", start_timestamp=0.0, end_timestamp=7199.0, # Less than video_duration user_prompt="Skim through entire video", video_duration=7200.0, ) # End timestamp within bounds should stay as-is assert input_data.end_timestamp == 7199.0 ================================================ FILE: agent/tests/unit_test/tools/test_video_skim_caption_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for video_skim_caption module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.video_skim_caption import VideoSkimCaptionConfig from vss_agents.tools.video_skim_caption import VideoSkimCaptionInput class TestVideoSkimCaptionConfig: """Test VideoSkimCaptionConfig model.""" def test_defaults(self): config = VideoSkimCaptionConfig() assert config.skim_fps == 0.5 def test_custom_fps(self): config = VideoSkimCaptionConfig(skim_fps=0.25) assert config.skim_fps == 0.25 class TestVideoSkimCaptionInput: """Test VideoSkimCaptionInput model.""" def test_valid_input(self): inp = VideoSkimCaptionInput( filename="long_video.mp4", start_timestamp=0.0, end_timestamp=300.0, user_prompt="Summarize", video_duration=600.0, ) assert inp.filename == "long_video.mp4" assert inp.start_timestamp == 0.0 assert inp.end_timestamp == 300.0 def test_end_timestamp_capped(self): inp = VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=1000.0, user_prompt="test", video_duration=100.0, ) assert inp.end_timestamp == pytest.approx(99.99) def test_end_timestamp_none(self): inp = VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=None, user_prompt="test", video_duration=200.0, ) assert inp.end_timestamp == pytest.approx(199.99) def test_zero_duration_raises(self): with pytest.raises(ValueError, match="Video duration must be positive"): VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="test", video_duration=0.0, ) def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): VideoSkimCaptionInput( filename="video.mp4", start_timestamp=0.0, end_timestamp=10.0, user_prompt="test", video_duration=100.0, extra="no", ) ================================================ FILE: agent/tests/unit_test/tools/test_video_understanding.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_understanding module.""" from vss_agents.tools.video_understanding import _parse_thinking_from_content class TestParseThinkingFromContent: """Test _parse_thinking_from_content function.""" def test_empty_content(self): """Test with empty content.""" thinking, answer = _parse_thinking_from_content("") assert thinking is None assert answer == "" def test_none_content(self): """Test with None content.""" thinking, answer = _parse_thinking_from_content(None) assert thinking is None assert answer is None def test_no_tags(self): """Test content without thinking tags.""" content = "This is a simple response without any tags." thinking, answer = _parse_thinking_from_content(content) assert thinking is None assert answer == content def test_think_and_answer_tags(self): """Test content with both and tags.""" content = "I need to analyze this video.The video shows a car." thinking, answer = _parse_thinking_from_content(content) assert thinking == "I need to analyze this video." assert answer == "The video shows a car." def test_only_think_tags(self): """Test content with only tags, no tags.""" content = "Analyzing the video...The result is positive." thinking, answer = _parse_thinking_from_content(content) assert thinking == "Analyzing the video..." assert answer == "The result is positive." def test_think_tags_with_whitespace(self): """Test content with whitespace around tags.""" content = " Thinking content Answer content " thinking, answer = _parse_thinking_from_content(content) assert "Thinking content" in thinking assert "Answer content" in answer def test_malformed_tags_start_after_end(self): """Test content where tags are in wrong order.""" content = "Content" _thinking, answer = _parse_thinking_from_content(content) # Should return original content when malformed assert answer == content def test_nested_content_in_think(self): """Test content with nested text in think tags.""" content = "Step 1: Analyze. Step 2: Conclude.Final answer here." thinking, answer = _parse_thinking_from_content(content) assert "Step 1" in thinking assert "Final answer" in answer def test_empty_think_tags(self): """Test content with empty think tags.""" content = "The answer is 42." thinking, answer = _parse_thinking_from_content(content) assert thinking == "" assert answer == "The answer is 42." def test_content_before_think(self): """Test content that has text before think tags.""" content = "Intro text Thinking hereAnswer here" thinking, answer = _parse_thinking_from_content(content) assert thinking == "Thinking here" assert answer == "Answer here" def test_empty_answer_after_think(self): """Test that empty answer returns empty string.""" content = "All reasoning here." thinking, answer = _parse_thinking_from_content(content) assert thinking == "All reasoning here." assert answer == "" ================================================ FILE: agent/tests/unit_test/tools/test_video_upload_url.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_upload_url module.""" from pydantic import ValidationError import pytest from vss_agents.api.video_upload_url import VideoUploadURLConfig from vss_agents.api.video_upload_url import VideoUploadURLInput from vss_agents.api.video_upload_url import VideoUploadURLOutput class TestVideoUploadURLConfig: """Test VideoUploadURLConfig model.""" def test_config_creation(self): config = VideoUploadURLConfig( vst_external_url="http://localhost:30888", agent_base_url="http://localhost:8000", ) assert config.vst_external_url == "http://localhost:30888" assert config.agent_base_url == "http://localhost:8000" def test_config_missing_vst_base_url(self): with pytest.raises(ValidationError): VideoUploadURLConfig( agent_base_url="http://localhost:8000", ) def test_config_missing_agent_base_url(self): with pytest.raises(ValidationError): VideoUploadURLConfig( vst_external_url="http://localhost:30888", ) class TestVideoUploadURLInput: """Test VideoUploadURLInput model.""" def test_input_basic(self): input_data = VideoUploadURLInput(filename="video.mp4") assert input_data.filename == "video.mp4" assert input_data.embedding is False def test_input_with_embedding(self): input_data = VideoUploadURLInput(filename="video.mp4", embedding=True) assert input_data.embedding is True def test_input_empty_filename_fails(self): with pytest.raises(ValidationError): VideoUploadURLInput(filename="") def test_input_with_extension(self): input_data = VideoUploadURLInput(filename="my_video.mp4") assert input_data.filename == "my_video.mp4" def test_input_without_extension(self): input_data = VideoUploadURLInput(filename="my_video") assert input_data.filename == "my_video" class TestVideoUploadURLOutput: """Test VideoUploadURLOutput model.""" def test_output_creation(self): output = VideoUploadURLOutput( url="http://localhost:30888/vst/api/v1/storage/file/video/2025-01-01T00:00:00.000Z" ) assert "video" in output.url def test_output_embedding_url(self): output = VideoUploadURLOutput(url="http://localhost:8000/api/v1/videos-for-search/my_video") assert "videos-for-search" in output.url def test_output_serialization(self): output = VideoUploadURLOutput(url="http://test.com/video") json_str = output.model_dump_json() assert "url" in json_str assert "http://test.com/video" in json_str ================================================ FILE: agent/tests/unit_test/tools/test_vss_summarize.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vss_summarize module.""" import uuid from pydantic import ValidationError import pytest from vss_agents.data_models.vss import MediaInfoOffset from vss_agents.prompt import INIT_SUMMARIZE_PROMPT from vss_agents.tools.vss_summarize import VSSSummarizeConfig from vss_agents.tools.vss_summarize import VSSSummarizeInput from vss_agents.tools.vss_summarize import VSSSummarizeOutput class TestVSSSummarizeConfig: """Test VSSSummarizeConfig model.""" def test_required_fields(self): config = VSSSummarizeConfig(backend_url="http://localhost:31000") assert config.backend_url == "http://localhost:31000" assert config.vss_version == "2.3.0" assert config.conn_timeout_ms == 5000 assert config.read_timeout_ms == 360000 assert config.max_concurrency == 4 assert config.max_num_frames_per_chunk == 8 def test_custom_values(self): config = VSSSummarizeConfig( backend_url="http://custom:8080", vss_version="3.0.0", conn_timeout_ms=10000, read_timeout_ms=600000, max_concurrency=8, max_num_frames_per_chunk=16, ) assert config.backend_url == "http://custom:8080" assert config.vss_version == "3.0.0" assert config.conn_timeout_ms == 10000 assert config.max_concurrency == 8 def test_missing_backend_url_raises(self): with pytest.raises(ValidationError): VSSSummarizeConfig() class TestVSSSummarizeInput: """Test VSSSummarizeInput model.""" def test_valid_input_with_uuid(self): test_uuid = uuid.uuid4() input_data = VSSSummarizeInput( id=test_uuid, prompt="Describe the video", video_duration=60.0, ) assert input_data.id == test_uuid assert input_data.prompt == "Describe the video" assert input_data.video_duration == 60.0 # media_info should be auto-created assert input_data.media_info.start_offset == 0 assert input_data.media_info.end_offset == 60 def test_valid_input_with_media_info(self): test_uuid = uuid.uuid4() media_info = MediaInfoOffset(start_offset=10, end_offset=50) input_data = VSSSummarizeInput( id=test_uuid, prompt="Describe the video", video_duration=60.0, media_info=media_info, ) assert input_data.media_info.start_offset == 10 assert input_data.media_info.end_offset == 50 def test_media_info_end_clamped_to_duration(self): test_uuid = uuid.uuid4() media_info = MediaInfoOffset(start_offset=10, end_offset=100) # end > duration input_data = VSSSummarizeInput( id=test_uuid, prompt="Describe the video", video_duration=60.0, media_info=media_info, ) # end_offset should be clamped to video_duration assert input_data.media_info.end_offset == 60 def test_step_size_bounds(self): test_uuid = uuid.uuid4() # Valid step_size input_data = VSSSummarizeInput( id=test_uuid, prompt="Describe", video_duration=60.0, step_size=1.0, ) assert input_data.step_size == 1.0 def test_step_size_too_small_raises(self): test_uuid = uuid.uuid4() with pytest.raises(ValidationError): VSSSummarizeInput( id=test_uuid, prompt="Describe", video_duration=60.0, step_size=0.05, # Less than 0.1 ) def test_step_size_too_large_raises(self): test_uuid = uuid.uuid4() with pytest.raises(ValidationError): VSSSummarizeInput( id=test_uuid, prompt="Describe", video_duration=60.0, step_size=15.0, # Greater than 10 ) def test_default_prompts(self): test_uuid = uuid.uuid4() input_data = VSSSummarizeInput( id=test_uuid, prompt="Describe", video_duration=60.0, ) assert input_data.summary_aggregation_prompt == INIT_SUMMARIZE_PROMPT["summary_aggregation_prompt"] assert input_data.caption_summarization_prompt == INIT_SUMMARIZE_PROMPT["caption_summarization_prompt"] def test_custom_prompts(self): test_uuid = uuid.uuid4() input_data = VSSSummarizeInput( id=test_uuid, prompt="Describe", video_duration=60.0, summary_aggregation_prompt="Custom aggregation prompt", caption_summarization_prompt="Custom summarization prompt", ) assert input_data.summary_aggregation_prompt == "Custom aggregation prompt" assert input_data.caption_summarization_prompt == "Custom summarization prompt" def test_list_of_uuids(self): test_uuids = [uuid.uuid4(), uuid.uuid4()] input_data = VSSSummarizeInput( id=test_uuids, prompt="Describe multiple videos", video_duration=120.0, ) assert input_data.id == test_uuids def test_prompt_max_length(self): test_uuid = uuid.uuid4() # Valid long prompt (under 5000 chars) long_prompt = "A" * 4999 input_data = VSSSummarizeInput( id=test_uuid, prompt=long_prompt, video_duration=60.0, ) assert len(input_data.prompt) == 4999 def test_prompt_exceeds_max_length_raises(self): test_uuid = uuid.uuid4() with pytest.raises(ValidationError): VSSSummarizeInput( id=test_uuid, prompt="A" * 5001, # Exceeds 5000 video_duration=60.0, ) class TestVSSSummarizeOutput: """Test VSSSummarizeOutput model.""" def test_valid_output(self): media_info = MediaInfoOffset(start_offset=0, end_offset=60) output = VSSSummarizeOutput( media_info=media_info, summary="This video shows a person walking.", step_size=1.0, ) assert output.summary == "This video shows a person walking." assert output.step_size == 1.0 def test_str_representation(self): media_info = MediaInfoOffset(start_offset=10, end_offset=50) output = VSSSummarizeOutput( media_info=media_info, summary="Test summary", step_size=0.5, ) str_repr = str(output) assert "10 - 50" in str_repr assert "0.5" in str_repr assert "Test summary" in str_repr assert "timestamp:" in str_repr assert "step size:" in str_repr assert "summary:" in str_repr def test_step_size_none(self): media_info = MediaInfoOffset(start_offset=0, end_offset=60) output = VSSSummarizeOutput( media_info=media_info, summary="Summary without step size", step_size=None, ) assert output.step_size is None def test_empty_summary(self): media_info = MediaInfoOffset(start_offset=0, end_offset=60) output = VSSSummarizeOutput( media_info=media_info, summary="", step_size=1.0, ) assert output.summary == "" ================================================ FILE: agent/tests/unit_test/tools/test_vss_summarize_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for vss_summarize module to improve coverage.""" import uuid from pydantic import ValidationError import pytest from vss_agents.data_models.vss import MediaInfoOffset from vss_agents.tools.vss_summarize import VSSSummarizeConfig from vss_agents.tools.vss_summarize import VSSSummarizeInput from vss_agents.tools.vss_summarize import VSSSummarizeOutput class TestVSSSummarizeConfig: """Test VSSSummarizeConfig model.""" def test_required_fields(self): config = VSSSummarizeConfig(backend_url="http://localhost:31000") assert config.backend_url == "http://localhost:31000" assert config.vss_version == "2.3.0" assert config.conn_timeout_ms == 5000 assert config.read_timeout_ms == 360000 assert config.max_concurrency == 4 assert config.max_num_frames_per_chunk == 8 def test_custom_config(self): config = VSSSummarizeConfig( backend_url="http://vss:9000", vss_version="3.0.0", conn_timeout_ms=10000, read_timeout_ms=600000, max_concurrency=8, max_num_frames_per_chunk=16, ) assert config.max_concurrency == 8 assert config.max_num_frames_per_chunk == 16 def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): VSSSummarizeConfig( backend_url="http://localhost:31000", unknown_field="value", ) class TestVSSSummarizeInput: """Test VSSSummarizeInput model.""" def test_basic_input(self): file_id = uuid.uuid4() inp = VSSSummarizeInput( id=file_id, prompt="Describe the scene", video_duration=60.0, ) assert inp.id == file_id assert inp.prompt == "Describe the scene" assert inp.video_duration == 60.0 # media_info should be auto-created assert inp.media_info.start_offset == 0 assert inp.media_info.end_offset == 60 def test_with_media_info(self): file_id = uuid.uuid4() media_info = MediaInfoOffset(start_offset=10, end_offset=50) inp = VSSSummarizeInput( id=file_id, prompt="test", video_duration=60.0, media_info=media_info, ) assert inp.media_info.start_offset == 10 assert inp.media_info.end_offset == 50 def test_media_info_end_capped_to_duration(self): file_id = uuid.uuid4() media_info = MediaInfoOffset(start_offset=0, end_offset=200) inp = VSSSummarizeInput( id=file_id, prompt="test", video_duration=60.0, media_info=media_info, ) assert inp.media_info.end_offset == 60 def test_step_size(self): file_id = uuid.uuid4() inp = VSSSummarizeInput( id=file_id, prompt="test", video_duration=60.0, step_size=1.0, ) assert inp.step_size == 1.0 def test_custom_prompts(self): file_id = uuid.uuid4() inp = VSSSummarizeInput( id=file_id, prompt="test", video_duration=60.0, caption_summarization_prompt="Custom caption prompt", summary_aggregation_prompt="Custom aggregation prompt", ) assert inp.caption_summarization_prompt == "Custom caption prompt" assert inp.summary_aggregation_prompt == "Custom aggregation prompt" def test_list_of_ids(self): ids = [uuid.uuid4(), uuid.uuid4()] inp = VSSSummarizeInput( id=ids, prompt="test", video_duration=60.0, ) assert len(inp.id) == 2 def test_extra_fields_forbidden(self): with pytest.raises(ValidationError): VSSSummarizeInput( id=uuid.uuid4(), prompt="test", video_duration=60.0, extra="not allowed", ) class TestVSSSummarizeOutput: """Test VSSSummarizeOutput model.""" def test_basic_output(self): output = VSSSummarizeOutput( media_info=MediaInfoOffset(start_offset=0, end_offset=60), summary="The video shows a parking lot.", step_size=1.0, ) assert "parking lot" in output.summary assert output.step_size == 1.0 def test_str_representation(self): output = VSSSummarizeOutput( media_info=MediaInfoOffset(start_offset=10, end_offset=50), summary="Test summary", step_size=2.0, ) result_str = str(output) assert "10 - 50" in result_str assert "Test summary" in result_str assert "2.0" in result_str def test_str_representation_no_step_size(self): output = VSSSummarizeOutput( media_info=MediaInfoOffset(start_offset=0, end_offset=30), summary="Summary", ) result_str = str(output) assert "0 - 30" in result_str ================================================ FILE: agent/tests/unit_test/tools/test_vss_summarize_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_summarize inner function via generator invocation.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import uuid import pytest from vss_agents.tools.vss_summarize import VSSSummarizeConfig from vss_agents.tools.vss_summarize import VSSSummarizeInput from vss_agents.tools.vss_summarize import VSSSummarizeOutput from vss_agents.tools.vss_summarize import vss_summarize class TestVSSSummarizeInner: """Test vss_summarize inner function.""" @pytest.fixture def config(self): return VSSSummarizeConfig( backend_url="http://localhost:31000", max_concurrency=4, max_num_frames_per_chunk=8, ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_summarize_success(self, config, mock_builder): # Mock requests.get for model list mock_requests_response = MagicMock() mock_requests_response.status_code = 200 mock_requests_response.json.return_value = {"data": [{"id": "cosmos-vlm"}]} # Mock aiohttp session for summarize call mock_aiohttp_response = MagicMock() mock_aiohttp_response.status = 200 mock_aiohttp_response.json = AsyncMock( return_value={"choices": [{"message": {"content": "Summary: A person walks across the lot."}}]} ) mock_aiohttp_cm = AsyncMock() mock_aiohttp_cm.__aenter__ = AsyncMock(return_value=mock_aiohttp_response) mock_aiohttp_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.post.return_value = mock_aiohttp_cm with patch("requests.get", return_value=mock_requests_response): with patch("aiohttp.ClientSession", return_value=mock_session): gen = vss_summarize.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn file_id = uuid.uuid4() inp = VSSSummarizeInput( id=file_id, prompt="Describe the scene", video_duration=60.0, ) result = await inner_fn(inp) assert isinstance(result, VSSSummarizeOutput) assert "person" in result.summary.lower() @pytest.mark.asyncio async def test_summarize_with_step_size(self, config, mock_builder): mock_requests_response = MagicMock() mock_requests_response.status_code = 200 mock_requests_response.json.return_value = {"data": [{"id": "vlm-model"}]} mock_aiohttp_response = MagicMock() mock_aiohttp_response.status = 200 mock_aiohttp_response.json = AsyncMock(return_value={"choices": [{"message": {"content": "Detailed summary"}}]}) mock_aiohttp_cm = AsyncMock() mock_aiohttp_cm.__aenter__ = AsyncMock(return_value=mock_aiohttp_response) mock_aiohttp_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.post.return_value = mock_aiohttp_cm with patch("requests.get", return_value=mock_requests_response): with patch("aiohttp.ClientSession", return_value=mock_session): gen = vss_summarize.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn file_id = uuid.uuid4() inp = VSSSummarizeInput( id=file_id, prompt="Describe", video_duration=60.0, step_size=1.0, ) result = await inner_fn(inp) assert isinstance(result, VSSSummarizeOutput) assert result.step_size == 1.0 @pytest.mark.asyncio async def test_summarize_api_error(self, config, mock_builder): mock_requests_response = MagicMock() mock_requests_response.status_code = 200 mock_requests_response.json.return_value = {"data": [{"id": "vlm"}]} mock_aiohttp_response = MagicMock() mock_aiohttp_response.status = 500 mock_aiohttp_response.text = "Internal server error" mock_aiohttp_cm = AsyncMock() mock_aiohttp_cm.__aenter__ = AsyncMock(return_value=mock_aiohttp_response) mock_aiohttp_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.post.return_value = mock_aiohttp_cm with patch("requests.get", return_value=mock_requests_response): with patch("aiohttp.ClientSession", return_value=mock_session): gen = vss_summarize.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn file_id = uuid.uuid4() inp = VSSSummarizeInput(id=file_id, prompt="test", video_duration=60.0) result = await inner_fn(inp) assert result.summary == "" @pytest.mark.asyncio async def test_summarize_empty_choices(self, config, mock_builder): mock_requests_response = MagicMock() mock_requests_response.status_code = 200 mock_requests_response.json.return_value = {"data": [{"id": "vlm"}]} mock_aiohttp_response = MagicMock() mock_aiohttp_response.status = 200 mock_aiohttp_response.json = AsyncMock(return_value={"choices": []}) mock_aiohttp_cm = AsyncMock() mock_aiohttp_cm.__aenter__ = AsyncMock(return_value=mock_aiohttp_response) mock_aiohttp_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.post.return_value = mock_aiohttp_cm with patch("requests.get", return_value=mock_requests_response): with patch("aiohttp.ClientSession", return_value=mock_session): gen = vss_summarize.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn file_id = uuid.uuid4() inp = VSSSummarizeInput(id=file_id, prompt="test", video_duration=60.0) result = await inner_fn(inp) assert result.summary == "" @pytest.mark.asyncio async def test_summarize_connection_error(self, config, mock_builder): mock_requests_response = MagicMock() mock_requests_response.status_code = 200 mock_requests_response.json.return_value = {"data": [{"id": "vlm"}]} mock_session = MagicMock() mock_aiohttp_cm = AsyncMock() mock_aiohttp_cm.__aenter__ = AsyncMock(side_effect=ConnectionError("cannot connect")) mock_aiohttp_cm.__aexit__ = AsyncMock(return_value=False) mock_session.post.return_value = mock_aiohttp_cm with patch("requests.get", return_value=mock_requests_response): with patch("aiohttp.ClientSession", return_value=mock_session): gen = vss_summarize.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn file_id = uuid.uuid4() inp = VSSSummarizeInput(id=file_id, prompt="test", video_duration=60.0) result = await inner_fn(inp) assert result.summary == "" @pytest.mark.asyncio async def test_init_model_error(self, config, mock_builder): mock_requests_response = MagicMock() mock_requests_response.status_code = 500 mock_requests_response.text = "Error" with patch("requests.get", return_value=mock_requests_response): with pytest.raises(RuntimeError, match="Failed to get model"): gen = vss_summarize.__wrapped__(config, mock_builder) await gen.__anext__() ================================================ FILE: agent/tests/unit_test/tools/test_vst_tools.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for VST tools modules (vst_download, vst_files).""" from vss_agents.tools.vst_download import VSTDownloadConfig from vss_agents.tools.vst_download import VSTDownloadInput from vss_agents.tools.vst_download import VSTDownloadOutput from vss_agents.tools.vst_files import VSTFilesConfig from vss_agents.tools.vst_files import VSTFilesInput class TestVSTDownloadConfig: """Test VSTDownloadConfig model.""" def test_with_required_field(self): config = VSTDownloadConfig(vst_backend_url="http://vst.example.com") assert config.vst_backend_url == "http://vst.example.com" assert config.download_timeout == 300 assert config.chunk_size == 8192 def test_custom_values(self): config = VSTDownloadConfig( vst_backend_url="http://vst.example.com", download_timeout=600, chunk_size=16384, ) assert config.download_timeout == 600 assert config.chunk_size == 16384 class TestVSTDownloadInput: """Test VSTDownloadInput model.""" def test_basic_input(self): input_data = VSTDownloadInput( video_id="video-123", filename="test.mp4", start_time=0, end_time=10000, asset_path="/tmp/videos", ) assert input_data.video_id == "video-123" assert input_data.filename == "test.mp4" assert input_data.start_time == 0 assert input_data.end_time == 10000 assert input_data.container == "mp4" assert input_data.asset_path == "/tmp/videos" def test_with_custom_container(self): input_data = VSTDownloadInput( video_id="video-123", filename="test.mkv", start_time=5000, end_time=15000, asset_path="/tmp/videos", container="mkv", ) assert input_data.container == "mkv" class TestVSTDownloadOutput: """Test VSTDownloadOutput model.""" def test_output_creation(self): output = VSTDownloadOutput( local_file_path="/tmp/videos/test.mp4", file_size_bytes=1024000, duration_ms=10000, ) assert output.local_file_path == "/tmp/videos/test.mp4" assert output.file_size_bytes == 1024000 assert output.duration_ms == 10000 assert output.cleanup_required is True def test_output_no_cleanup(self): output = VSTDownloadOutput( local_file_path="/tmp/videos/test.mp4", file_size_bytes=1024000, duration_ms=10000, cleanup_required=False, ) assert output.cleanup_required is False class TestVSTFilesConfig: """Test VSTFilesConfig model.""" def test_with_required_field(self): config = VSTFilesConfig(vst_backend_url="http://vst.example.com") assert config.vst_backend_url == "http://vst.example.com" assert config.timeout == 30 assert config.use_mock is True assert config.offset == 0 assert config.limit == 100 assert "b7a1c1f2-9c0e-4d8d-8a6a-2e5f7d2e3c1b" in config.mock_video_list def test_custom_values(self): config = VSTFilesConfig( vst_backend_url="http://vst.example.com", timeout=60, use_mock=False, offset=10, limit=50, ) assert config.timeout == 60 assert config.use_mock is False assert config.offset == 10 assert config.limit == 50 class TestVSTFilesInput: """Test VSTFilesInput model.""" def test_basic_input(self): input_data = VSTFilesInput(question="Show me all videos from today") assert input_data.question == "Show me all videos from today" ================================================ FILE: agent/tests/unit_test/tools/vst/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: agent/tests/unit_test/tools/vst/test_bounding_box.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for bounding box overlay support in VST snapshot and video clip tools. Tests the unified build_overlay_config helper and its integration with both the snapshot and video_clip tools. """ import json from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import urllib.parse import pytest from vss_agents.tools.vst.snapshot import VSTSnapshotConfig from vss_agents.tools.vst.snapshot import VSTSnapshotISOInput from vss_agents.tools.vst.snapshot import VSTSnapshotOutput from vss_agents.tools.vst.snapshot import get_snapshot_url from vss_agents.tools.vst.snapshot import vst_snapshot from vss_agents.tools.vst.utils import build_overlay_config from vss_agents.tools.vst.video_clip import get_video_url class TestBuildOverlayConfig: """Test the shared build_overlay_config helper function.""" def test_overlay_disabled_returns_none(self): """When overlay is disabled, should return None.""" result = build_overlay_config(overlay_enabled=False) assert result is None def test_overlay_disabled_with_object_ids_returns_none(self): """When overlay is disabled, should return None even with object_ids.""" result = build_overlay_config(overlay_enabled=False, object_ids=["obj-1"]) assert result is None def test_overlay_enabled_no_object_ids_shows_all(self): """When overlay is enabled without object_ids, showAll should be True.""" result = build_overlay_config(overlay_enabled=True) assert result is not None decoded = json.loads(urllib.parse.unquote(result)) assert decoded["overlay"]["bbox"]["showAll"] is True assert decoded["overlay"]["bbox"]["objectId"] == [] assert decoded["overlay"]["color"] == "green" assert decoded["overlay"]["thickness"] == 5 assert decoded["overlay"]["debug"] is True assert decoded["overlay"]["opacity"] == 254 def test_overlay_enabled_with_empty_object_ids(self): """When overlay is enabled with empty list, showAll should be True.""" result = build_overlay_config(overlay_enabled=True, object_ids=[]) assert result is not None decoded = json.loads(urllib.parse.unquote(result)) assert decoded["overlay"]["bbox"]["showAll"] is True assert decoded["overlay"]["bbox"]["objectId"] == [] def test_overlay_enabled_with_object_ids(self): """When overlay is enabled with specific object_ids, showAll should be False.""" result = build_overlay_config(overlay_enabled=True, object_ids=["obj-1", "obj-2"]) assert result is not None decoded = json.loads(urllib.parse.unquote(result)) assert decoded["overlay"]["bbox"]["showAll"] is False assert decoded["overlay"]["bbox"]["objectId"] == ["obj-1", "obj-2"] def test_overlay_result_is_url_encoded(self): """The result should be URL-encoded.""" result = build_overlay_config(overlay_enabled=True) assert result is not None # Should be URL-encoded (contains %7B for {, etc.) assert "{" not in result assert "}" not in result # Should decode to valid JSON decoded = json.loads(urllib.parse.unquote(result)) assert "overlay" in decoded def test_overlay_with_single_object_id(self): """Test overlay with a single object_id.""" result = build_overlay_config(overlay_enabled=True, object_ids=["person-42"]) decoded = json.loads(urllib.parse.unquote(result)) assert decoded["overlay"]["bbox"]["showAll"] is False assert decoded["overlay"]["bbox"]["objectId"] == ["person-42"] class TestSnapshotBoundingBox: """Test bounding box overlay support in the snapshot tool.""" @pytest.fixture def config_with_overlay(self): return VSTSnapshotConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", overlay_config=True, time_format="iso", ) @pytest.fixture def config_without_overlay(self): return VSTSnapshotConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", overlay_config=False, ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_get_snapshot_url_with_overlay(self): """Test that get_snapshot_url includes overlay param when enabled.""" mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"imageUrl": "http://10.0.0.1:30888/vst/img.jpg"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.snapshot.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.snapshot.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() result = await get_snapshot_url( "stream-uuid", "2025-01-01T00:05:00.000Z", "http://10.0.0.1:30888", overlay_enabled=True, ) assert result == "http://10.0.0.1:30888/vst/img.jpg" # Verify the URL contained the overlay parameter actual_url = mock_session.get.call_args[0][0] assert "overlay=" in actual_url # Decode and verify the overlay parameter overlay_part = actual_url.split("overlay=")[1] overlay_config = json.loads(urllib.parse.unquote(overlay_part)) assert overlay_config["overlay"]["bbox"]["showAll"] is True @pytest.mark.asyncio async def test_get_snapshot_url_without_overlay(self): """Test that get_snapshot_url does not include overlay param when disabled.""" mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"imageUrl": "http://10.0.0.1:30888/vst/img.jpg"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.snapshot.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.snapshot.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() result = await get_snapshot_url( "stream-uuid", "2025-01-01T00:05:00.000Z", "http://10.0.0.1:30888", overlay_enabled=False, ) assert result == "http://10.0.0.1:30888/vst/img.jpg" # Verify the URL does NOT contain the overlay parameter actual_url = mock_session.get.call_args[0][0] assert "overlay=" not in actual_url @pytest.mark.asyncio async def test_snapshot_tool_passes_overlay_config(self, config_with_overlay, mock_builder): """Test that the snapshot tool passes overlay_config to get_snapshot_url.""" with patch("vss_agents.tools.vst.snapshot.get_stream_id", new_callable=AsyncMock) as mock_get_id: mock_get_id.return_value = "stream-uuid" with patch("vss_agents.tools.vst.snapshot.get_snapshot_url", new_callable=AsyncMock) as mock_get_url: mock_get_url.return_value = "http://10.0.0.1:30888/vst/img.jpg" gen = vst_snapshot.__wrapped__(config_with_overlay, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VSTSnapshotISOInput(sensor_id="camera1", start_time="2025-01-01T00:05:00.000Z") result = await inner_fn(inp) assert isinstance(result, VSTSnapshotOutput) # Verify overlay_enabled was passed as True mock_get_url.assert_called_once_with( "stream-uuid", "2025-01-01T00:05:00.000Z", "http://10.0.0.1:30888", overlay_enabled=True, ) class TestVideoClipBoundingBox: """Test bounding box overlay support in the video clip tool.""" @pytest.mark.asyncio async def test_get_video_url_with_overlay_and_object_ids(self): """Test that get_video_url includes overlay+object_ids in the URL.""" mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"videoUrl": "http://vst/clip.mp4"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.video_clip.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.video_clip.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() result = await get_video_url( "stream1", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T00:10:00.000Z", vst_internal_url="http://vst:30888", overlay_enabled=True, object_ids=["person-1", "vehicle-2"], ) assert result == "http://vst/clip.mp4" # Verify URL contained configuration param with overlay actual_url = mock_session.get.call_args[0][0] assert "configuration=" in actual_url config_part = actual_url.split("configuration=")[1] config_data = json.loads(urllib.parse.unquote(config_part)) assert config_data["overlay"]["bbox"]["showAll"] is False assert config_data["overlay"]["bbox"]["objectId"] == ["person-1", "vehicle-2"] @pytest.mark.asyncio async def test_get_video_url_with_overlay_no_object_ids(self): """Test that get_video_url with overlay but no object_ids shows all bboxes.""" mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"videoUrl": "http://vst/clip.mp4"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.video_clip.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.video_clip.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() result = await get_video_url( "stream1", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T00:10:00.000Z", vst_internal_url="http://vst:30888", overlay_enabled=True, ) assert result == "http://vst/clip.mp4" actual_url = mock_session.get.call_args[0][0] assert "configuration=" in actual_url config_part = actual_url.split("configuration=")[1] config_data = json.loads(urllib.parse.unquote(config_part)) assert config_data["overlay"]["bbox"]["showAll"] is True @pytest.mark.asyncio async def test_get_video_url_without_overlay(self): """Test that get_video_url without overlay does not include configuration param.""" mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"videoUrl": "http://vst/clip.mp4"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.video_clip.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.video_clip.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() result = await get_video_url( "stream1", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T00:10:00.000Z", vst_internal_url="http://vst:30888", overlay_enabled=False, ) assert result == "http://vst/clip.mp4" actual_url = mock_session.get.call_args[0][0] assert "configuration=" not in actual_url ================================================ FILE: agent/tests/unit_test/tools/vst/test_duration_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for vst.duration module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.duration import VSTDurationConfig from vss_agents.tools.vst.duration import VSTDurationInput from vss_agents.tools.vst.duration import VSTDurationOutput class TestVSTDurationConfig: """Test VSTDurationConfig model.""" def test_required_fields(self): config = VSTDurationConfig(vst_internal_url="http://10.0.0.1:30888") assert config.vst_internal_url == "http://10.0.0.1:30888" def test_missing_url_raises(self): with pytest.raises(ValidationError): VSTDurationConfig() class TestVSTDurationInput: """Test VSTDurationInput model.""" def test_valid(self): inp = VSTDurationInput(sensor_id="camera1") assert inp.sensor_id == "camera1" def test_empty_sensor_id_raises(self): with pytest.raises(ValidationError): VSTDurationInput(sensor_id="") def test_missing_sensor_id_raises(self): with pytest.raises(ValidationError): VSTDurationInput() class TestVSTDurationOutput: """Test VSTDurationOutput model.""" def test_valid(self): output = VSTDurationOutput(duration=300.0) assert output.duration == 300.0 def test_missing_duration_raises(self): with pytest.raises(ValidationError): VSTDurationOutput() ================================================ FILE: agent/tests/unit_test/tools/vst/test_sensor_list.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vst.sensor_list module.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.sensor_list import VSTSensorListConfig from vss_agents.tools.vst.sensor_list import VSTSensorListInput from vss_agents.tools.vst.sensor_list import VSTSensorListOutput class TestVSTSensorListConfig: """Test VSTSensorListConfig model.""" def test_required_fields(self): config = VSTSensorListConfig( vst_internal_url="http://localhost:30888", ) assert config.vst_internal_url == "http://localhost:30888" def test_missing_required_fields(self): with pytest.raises(ValidationError): VSTSensorListConfig() class TestVSTSensorListInput: """Test VSTSensorListInput model.""" def test_empty_input(self): input_data = VSTSensorListInput() assert input_data is not None class TestVSTSensorListOutput: """Test VSTSensorListOutput model.""" def test_empty_list(self): output = VSTSensorListOutput(sensor_names=[]) assert output.sensor_names == [] def test_single_sensor(self): output = VSTSensorListOutput(sensor_names=["camera-001"]) assert output.sensor_names == ["camera-001"] assert len(output.sensor_names) == 1 def test_multiple_sensors(self): sensors = ["camera-001", "camera-002", "camera-003"] output = VSTSensorListOutput(sensor_names=sensors) assert output.sensor_names == sensors assert len(output.sensor_names) == 3 def test_serialization(self): output = VSTSensorListOutput(sensor_names=["sensor-a", "sensor-b"]) data = output.model_dump() assert "sensor_names" in data assert data["sensor_names"] == ["sensor-a", "sensor-b"] def test_various_sensor_names(self): sensor_names = [ "Main Street Camera", "sensor-001", "CAMERA_ABC_123", "camera.prod.east.1", ] output = VSTSensorListOutput(sensor_names=sensor_names) assert len(output.sensor_names) == 4 for name in sensor_names: assert name in output.sensor_names ================================================ FILE: agent/tests/unit_test/tools/vst/test_snapshot.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for VST snapshot module.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.snapshot import VSTSnapshotConfig from vss_agents.tools.vst.snapshot import VSTSnapshotISOInput from vss_agents.tools.vst.snapshot import VSTSnapshotOffsetInput from vss_agents.tools.vst.snapshot import VSTSnapshotOutput class TestVSTSnapshotConfig: """Test VSTSnapshotConfig model.""" def test_valid_config(self): """Test creating config with valid URLs.""" config = VSTSnapshotConfig(vst_internal_url="http://localhost:30888", vst_external_url="http://localhost:30888") assert config.vst_internal_url == "http://localhost:30888" assert config.vst_external_url == "http://localhost:30888" assert config.overlay_config is False assert config.time_format == "offset" def test_config_with_overlay(self): """Test config with overlay enabled.""" config = VSTSnapshotConfig( vst_internal_url="http://localhost:30888", vst_external_url="http://localhost:30888", overlay_config=True, ) assert config.overlay_config is True def test_config_with_time_format_iso(self): """Test config with ISO timestamp format.""" config = VSTSnapshotConfig( vst_internal_url="http://localhost:30888", vst_external_url="http://localhost:30888", time_format="iso", ) assert config.time_format == "iso" def test_config_with_time_format_offset(self): """Test config with offset timestamp format (default).""" config = VSTSnapshotConfig( vst_internal_url="http://localhost:30888", vst_external_url="http://localhost:30888", time_format="offset", ) assert config.time_format == "offset" def test_config_with_host_ip_placeholder(self): """Test config with HOST_IP placeholder.""" config = VSTSnapshotConfig( vst_internal_url="http://${HOST_IP}:30888", vst_external_url="http://${HOST_IP}:30888" ) assert config.vst_internal_url == "http://${HOST_IP}:30888" assert config.vst_external_url == "http://${HOST_IP}:30888" def test_config_with_trailing_slash(self): """Test config with trailing slash in URL.""" config = VSTSnapshotConfig( vst_internal_url="http://localhost:30888/", vst_external_url="http://localhost:30888/" ) assert config.vst_internal_url == "http://localhost:30888/" assert config.vst_external_url == "http://localhost:30888/" def test_missing_vst_urls_raises(self): """Test that missing URLs raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotConfig() def test_config_description(self): """Test that config has proper field description.""" field_info = VSTSnapshotConfig.model_fields["vst_internal_url"] assert "internal" in field_info.description.lower() class TestVSTSnapshotOffsetInput: """Test VSTSnapshotOffsetInput model.""" def test_valid_input_with_seconds(self): """Test creating input with valid sensor_id and start_time in seconds.""" input_data = VSTSnapshotOffsetInput(sensor_id="carryingcomputer_1", start_time=5.0) assert input_data.sensor_id == "carryingcomputer_1" assert input_data.start_time == 5.0 def test_input_with_zero_start_time(self): """Test input with zero start_time.""" input_data = VSTSnapshotOffsetInput(sensor_id="test_video", start_time=0.0) assert input_data.start_time == 0.0 def test_input_with_large_start_time(self): """Test input with large start_time value.""" input_data = VSTSnapshotOffsetInput(sensor_id="test_video", start_time=3600.5) assert input_data.start_time == 3600.5 def test_missing_sensor_id_raises(self): """Test that missing sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotOffsetInput(start_time=5.0) def test_empty_sensor_id_raises(self): """Test that empty sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotOffsetInput(sensor_id="", start_time=5.0) def test_missing_start_time_raises(self): """Test that missing start_time raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotOffsetInput(sensor_id="test_video") def test_input_descriptions(self): """Test that input fields have proper descriptions.""" sensor_field = VSTSnapshotOffsetInput.model_fields["sensor_id"] start_time_field = VSTSnapshotOffsetInput.model_fields["start_time"] assert "video" in sensor_field.description.lower() or "name" in sensor_field.description.lower() assert "seconds" in start_time_field.description.lower() class TestVSTSnapshotISOInput: """Test VSTSnapshotISOInput model.""" def test_valid_input_with_iso_timestamp(self): """Test creating input with ISO 8601 timestamp.""" input_data = VSTSnapshotISOInput(sensor_id="camera-001", start_time="2025-08-25T03:05:55.752Z") assert input_data.sensor_id == "camera-001" assert input_data.start_time == "2025-08-25T03:05:55.752Z" def test_missing_sensor_id_raises(self): """Test that missing sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotISOInput(start_time="2025-08-25T03:05:55.752Z") def test_empty_sensor_id_raises(self): """Test that empty sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotISOInput(sensor_id="", start_time="2025-08-25T03:05:55.752Z") def test_missing_start_time_raises(self): """Test that missing start_time raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotISOInput(sensor_id="test_video") def test_empty_start_time_raises(self): """Test that empty start_time raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotISOInput(sensor_id="test_video", start_time="") def test_input_descriptions(self): """Test that input fields have proper descriptions.""" sensor_field = VSTSnapshotISOInput.model_fields["sensor_id"] start_time_field = VSTSnapshotISOInput.model_fields["start_time"] assert "video" in sensor_field.description.lower() or "name" in sensor_field.description.lower() assert "iso" in start_time_field.description.lower() or "8601" in start_time_field.description.lower() class TestVSTSnapshotOutput: """Test VSTSnapshotOutput model.""" def test_valid_output(self): """Test creating output with valid image_url and stream_id.""" output = VSTSnapshotOutput( image_url="http://localhost:30888/snapshot/image.jpg", stream_id="24c5a7d6-39ce-442e-abf0-430f036b7a3d", ) assert output.image_url == "http://localhost:30888/snapshot/image.jpg" assert output.stream_id == "24c5a7d6-39ce-442e-abf0-430f036b7a3d" def test_output_with_real_url_format(self): """Test output with URL format from real VST server.""" output = VSTSnapshotOutput( image_url="http://10.0.0.1:30888/vst/api/v1/replay/stream/24c5a7d6-39ce-442e-abf0-430f036b7a3d/picture?startTime=2025-01-01T00:00:05.000Z", stream_id="24c5a7d6-39ce-442e-abf0-430f036b7a3d", ) assert "24c5a7d6-39ce-442e-abf0-430f036b7a3d" in output.image_url def test_missing_image_url_raises(self): """Test that missing image_url raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotOutput(stream_id="stream-uuid") def test_missing_stream_id_raises(self): """Test that missing stream_id raises ValidationError.""" with pytest.raises(ValidationError): VSTSnapshotOutput(image_url="http://example.com/snapshot.jpg") def test_output_json_serializable(self): """Test that output can be serialized to JSON.""" output = VSTSnapshotOutput( image_url="http://example.com/snapshot.jpg", stream_id="test-stream-id", ) json_str = output.model_dump_json() assert "http://example.com/snapshot.jpg" in json_str assert "test-stream-id" in json_str def test_output_description(self): """Test that output field has proper description.""" field_info = VSTSnapshotOutput.model_fields["image_url"] assert "URL" in field_info.description or "image" in field_info.description.lower() ================================================ FILE: agent/tests/unit_test/tools/vst/test_snapshot_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for vst.snapshot module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.snapshot import VSTSnapshotConfig from vss_agents.tools.vst.snapshot import VSTSnapshotISOInput from vss_agents.tools.vst.snapshot import VSTSnapshotOffsetInput from vss_agents.tools.vst.snapshot import VSTSnapshotOutput class TestVSTSnapshotConfig: """Test VSTSnapshotConfig model.""" def test_required_fields(self): config = VSTSnapshotConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", ) assert config.vst_internal_url == "http://10.0.0.1:30888" assert config.vst_external_url == "http://1.2.3.4:30888" assert config.overlay_config is False assert config.time_format == "offset" def test_missing_fields_raises(self): with pytest.raises(ValidationError): VSTSnapshotConfig(vst_internal_url="http://10.0.0.1:30888") def test_overlay_config_enabled(self): config = VSTSnapshotConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", overlay_config=True, ) assert config.overlay_config is True def test_time_format_iso(self): config = VSTSnapshotConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", time_format="iso", ) assert config.time_format == "iso" class TestVSTSnapshotOffsetInput: """Test VSTSnapshotOffsetInput model.""" def test_valid_input_seconds(self): inp = VSTSnapshotOffsetInput( sensor_id="camera1", start_time=30.0, ) assert inp.sensor_id == "camera1" assert inp.start_time == 30.0 def test_empty_sensor_id_raises(self): with pytest.raises(ValidationError): VSTSnapshotOffsetInput(sensor_id="", start_time=10.0) def test_zero_start_time(self): inp = VSTSnapshotOffsetInput(sensor_id="cam1", start_time=0.0) assert inp.start_time == 0.0 def test_missing_fields_raises(self): with pytest.raises(ValidationError): VSTSnapshotOffsetInput(sensor_id="cam1") class TestVSTSnapshotISOInput: """Test VSTSnapshotISOInput model.""" def test_valid_input_iso_timestamp(self): inp = VSTSnapshotISOInput( sensor_id="camera1", start_time="2025-08-25T03:05:55.752Z", ) assert inp.sensor_id == "camera1" assert inp.start_time == "2025-08-25T03:05:55.752Z" def test_empty_sensor_id_raises(self): with pytest.raises(ValidationError): VSTSnapshotISOInput(sensor_id="", start_time="2025-08-25T03:05:55.752Z") def test_missing_start_time_raises(self): with pytest.raises(ValidationError): VSTSnapshotISOInput(sensor_id="cam1") def test_empty_start_time_raises(self): with pytest.raises(ValidationError): VSTSnapshotISOInput(sensor_id="cam1", start_time="") class TestVSTSnapshotOutput: """Test VSTSnapshotOutput model.""" def test_valid(self): output = VSTSnapshotOutput(image_url="http://example.com/img.jpg", stream_id="stream-uuid") assert output.image_url == "http://example.com/img.jpg" assert output.stream_id == "stream-uuid" def test_missing_url_raises(self): with pytest.raises(ValidationError): VSTSnapshotOutput(stream_id="stream-uuid") def test_missing_stream_id_raises(self): with pytest.raises(ValidationError): VSTSnapshotOutput(image_url="http://example.com/img.jpg") def test_serialization(self): output = VSTSnapshotOutput(image_url="http://example.com/img.jpg", stream_id="stream-uuid") data = output.model_dump() assert "image_url" in data assert "stream_id" in data ================================================ FILE: agent/tests/unit_test/tools/vst/test_snapshot_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vst.snapshot inner function.""" import json from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.tools.vst.snapshot import VSTSnapshotConfig from vss_agents.tools.vst.snapshot import VSTSnapshotISOInput from vss_agents.tools.vst.snapshot import VSTSnapshotOffsetInput from vss_agents.tools.vst.snapshot import VSTSnapshotOutput from vss_agents.tools.vst.snapshot import vst_snapshot class TestVSTSnapshotInner: """Test vst_snapshot inner function.""" @pytest.fixture def config(self): return VSTSnapshotConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", ) @pytest.fixture def config_iso(self): return VSTSnapshotConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", time_format="iso", ) @pytest.fixture def config_with_overlay(self): return VSTSnapshotConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", overlay_config=True, ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_snapshot_success_with_seconds(self, config, mock_builder): """Test snapshot with seconds-based start_time.""" with patch("vss_agents.tools.vst.snapshot.get_stream_id", new_callable=AsyncMock) as mock_get_id: mock_get_id.return_value = "stream-uuid" with patch("vss_agents.tools.vst.snapshot.get_timeline", new_callable=AsyncMock) as mock_timeline: mock_timeline.return_value = ("2025-01-01T00:00:00.000+00:00", "2025-01-01T01:00:00.000+00:00") mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock( return_value=json.dumps({"imageUrl": "http://10.0.0.1:30888/vst/img.jpg"}) ) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.snapshot.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.snapshot.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock( __enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False) ) mock_retry.return_value = fake_retry() gen = vst_snapshot.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VSTSnapshotOffsetInput(sensor_id="camera1", start_time=30.0) result = await inner_fn(inp) assert isinstance(result, VSTSnapshotOutput) assert "1.2.3.4:30888" in result.image_url assert result.stream_id == "stream-uuid" @pytest.mark.asyncio async def test_snapshot_success_with_iso_timestamp(self, config_iso, mock_builder): """Test snapshot with ISO 8601 timestamp start_time.""" with patch("vss_agents.tools.vst.snapshot.get_stream_id", new_callable=AsyncMock) as mock_get_id: mock_get_id.return_value = "stream-uuid" mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"imageUrl": "http://10.0.0.1:30888/vst/img.jpg"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.snapshot.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.snapshot.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() gen = vst_snapshot.__wrapped__(config_iso, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VSTSnapshotISOInput(sensor_id="camera1", start_time="2025-01-01T00:05:00.000Z") result = await inner_fn(inp) assert isinstance(result, VSTSnapshotOutput) assert "1.2.3.4:30888" in result.image_url assert result.stream_id == "stream-uuid" @pytest.mark.asyncio async def test_snapshot_uses_correct_input_schema_offset(self, config, mock_builder): """Test that offset mode uses VSTSnapshotOffsetInput schema.""" gen = vst_snapshot.__wrapped__(config, mock_builder) fi = await gen.__anext__() assert fi.input_schema is VSTSnapshotOffsetInput @pytest.mark.asyncio async def test_snapshot_uses_correct_input_schema_iso(self, config_iso, mock_builder): """Test that iso mode uses VSTSnapshotISOInput schema.""" gen = vst_snapshot.__wrapped__(config_iso, mock_builder) fi = await gen.__anext__() assert fi.input_schema is VSTSnapshotISOInput @pytest.mark.asyncio async def test_snapshot_out_of_range(self, config, mock_builder): with patch("vss_agents.tools.vst.snapshot.get_stream_id", new_callable=AsyncMock) as mock_get_id: mock_get_id.return_value = "stream-uuid" with patch("vss_agents.tools.vst.snapshot.get_timeline", new_callable=AsyncMock) as mock_timeline: # Short video - 10 seconds mock_timeline.return_value = ("2025-01-01T00:00:00.000+00:00", "2025-01-01T00:00:10.000+00:00") mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock( return_value=json.dumps({"imageUrl": "http://10.0.0.1:30888/vst/img.jpg"}) ) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.snapshot.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.snapshot.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock( __enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False) ) mock_retry.return_value = fake_retry() gen = vst_snapshot.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn # Request timestamp beyond the timeline inp = VSTSnapshotOffsetInput(sensor_id="camera1", start_time=60.0) with pytest.raises(ValueError, match="out of the video timeline"): await inner_fn(inp) ================================================ FILE: agent/tests/unit_test/tools/vst/test_stream_list.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for VST video_list module.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.video_list import VSTVideoListConfig from vss_agents.tools.vst.video_list import VSTVideoListInput class TestVSTVideoListConfig: """Test VSTStreamListConfig model.""" def test_valid_config(self): """Test creating config with valid vst_internal_url.""" config = VSTVideoListConfig(vst_internal_url="http://localhost:30888") assert config.vst_internal_url == "http://localhost:30888" def test_config_with_trailing_slash(self): """Test config with trailing slash in URL.""" config = VSTVideoListConfig(vst_internal_url="http://localhost:30888/") assert config.vst_internal_url == "http://localhost:30888/" def test_config_with_vst_suffix(self): """Test config with /vst suffix in URL.""" config = VSTVideoListConfig(vst_internal_url="http://localhost:30888/vst") assert config.vst_internal_url == "http://localhost:30888/vst" def test_missing_vst_internal_url_raises(self): """Test that missing vst_internal_url raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoListConfig() def test_config_inherits_function_base_config(self): """Test that config has properties from FunctionBaseConfig.""" config = VSTVideoListConfig(vst_internal_url="http://localhost:30888") # FunctionBaseConfig should provide a name attribute through registration assert hasattr(config, "vst_internal_url") class TestVSTStreamListInput: """Test VSTStreamListInput model.""" def test_empty_input(self): """Test creating input with no parameters (pass is the only field).""" input_data = VSTVideoListInput() assert input_data is not None def test_input_is_pydantic_model(self): """Test that input is a valid Pydantic model.""" input_data = VSTVideoListInput() # Should be serializable to dict data_dict = input_data.model_dump() assert isinstance(data_dict, dict) def test_input_json_serializable(self): """Test that input can be serialized to JSON.""" input_data = VSTVideoListInput() json_str = input_data.model_dump_json() assert json_str == "{}" ================================================ FILE: agent/tests/unit_test/tools/vst/test_timeline.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for VST timeline module.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.timeline import VSTTimelineConfig from vss_agents.tools.vst.timeline import VSTTimelineInput from vss_agents.tools.vst.timeline import VSTTimelineOutput class TestVSTTimelineConfig: """Test VSTTimelineConfig model.""" def test_valid_config(self): """Test creating config with valid vst_internal_url.""" config = VSTTimelineConfig(vst_internal_url="http://localhost:30888") assert config.vst_internal_url == "http://localhost:30888" def test_config_with_trailing_slash(self): """Test config with trailing slash in URL.""" config = VSTTimelineConfig(vst_internal_url="http://localhost:30888/") assert config.vst_internal_url == "http://localhost:30888/" def test_missing_vst_internal_url_raises(self): """Test that missing vst_internal_url raises ValidationError.""" with pytest.raises(ValidationError): VSTTimelineConfig() def test_config_description(self): """Test that config has proper field description.""" # Access field info from model_fields field_info = VSTTimelineConfig.model_fields["vst_internal_url"] assert "internal" in field_info.description.lower() class TestVSTTimelineInput: """Test VSTTimelineInput model.""" def test_valid_sensor_id(self): """Test creating input with valid sensor_id.""" input_data = VSTTimelineInput(sensor_id="carryingcomputer_1") assert input_data.sensor_id == "carryingcomputer_1" def test_sensor_id_with_uuid(self): """Test creating input with UUID sensor_id.""" input_data = VSTTimelineInput(sensor_id="24c5a7d6-39ce-442e-abf0-430f036b7a3d") assert input_data.sensor_id == "24c5a7d6-39ce-442e-abf0-430f036b7a3d" def test_missing_sensor_id_raises(self): """Test that missing sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTTimelineInput() def test_input_description(self): """Test that input has proper field description.""" field_info = VSTTimelineInput.model_fields["sensor_id"] assert "sensor" in field_info.description.lower() or "stream ID" in field_info.description class TestVSTTimelineOutput: """Test VSTTimelineOutput model.""" def test_valid_output(self): """Test creating output with valid timestamps.""" output = VSTTimelineOutput( start_timestamp="2025-01-01T00:00:00.000Z", end_timestamp="2025-01-01T00:00:12.000Z", ) assert output.start_timestamp == "2025-01-01T00:00:00.000Z" assert output.end_timestamp == "2025-01-01T00:00:12.000Z" def test_output_with_real_data_timestamps(self): """Test output with timestamps from real VST server.""" output = VSTTimelineOutput( start_timestamp="2025-12-18T07:19:59.332Z", end_timestamp="2025-12-18T07:20:11.332Z", ) assert output.start_timestamp == "2025-12-18T07:19:59.332Z" assert output.end_timestamp == "2025-12-18T07:20:11.332Z" def test_missing_start_timestamp_raises(self): """Test that missing start_timestamp raises ValidationError.""" with pytest.raises(ValidationError): VSTTimelineOutput(end_timestamp="2025-01-01T00:00:12.000Z") def test_missing_end_timestamp_raises(self): """Test that missing end_timestamp raises ValidationError.""" with pytest.raises(ValidationError): VSTTimelineOutput(start_timestamp="2025-01-01T00:00:00.000Z") def test_output_json_serializable(self): """Test that output can be serialized to JSON.""" output = VSTTimelineOutput( start_timestamp="2025-01-01T00:00:00.000Z", end_timestamp="2025-01-01T00:00:12.000Z", ) json_str = output.model_dump_json() assert "2025-01-01T00:00:00.000Z" in json_str assert "2025-01-01T00:00:12.000Z" in json_str def test_output_descriptions(self): """Test that output fields have proper descriptions.""" start_field = VSTTimelineOutput.model_fields["start_timestamp"] end_field = VSTTimelineOutput.model_fields["end_timestamp"] assert "start" in start_field.description.lower() assert "end" in end_field.description.lower() ================================================ FILE: agent/tests/unit_test/tools/vst/test_utils.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for VST utils module.""" import json from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.tools.vst.timeline import get_timeline from vss_agents.tools.vst.utils import VSTError from vss_agents.tools.vst.utils import get_name_to_stream_id_map from vss_agents.tools.vst.utils import validate_video_url # Sample mock data based on real VST server responses MOCK_STREAMS_RESPONSE = [ { "24c5a7d6-39ce-442e-abf0-430f036b7a3d": [ { "isMain": True, "metadata": { "bitrate": "", "codec": "h264", "framerate": "30.0", "govlength": "", "resolution": "1920x1080", }, "name": "carryingcomputer_1", "storageLocation": "Local", "streamId": "24c5a7d6-39ce-442e-abf0-430f036b7a3d", "type": "Rtsp", "url": "/home/vst/vst_release/streamer_videos/carryingcomputer_1.mp4", "vodUrl": "/home/vst/vst_release/streamer_videos/carryingcomputer_1.mp4", } ] }, { "490bd636-32c3-4bcf-b1a6-f185d359631c": [ { "isMain": True, "metadata": { "bitrate": "", "codec": "h264", "framerate": "25", "govlength": "", "resolution": "794x720", }, "name": "its_short2", "storageLocation": "Local", "streamId": "490bd636-32c3-4bcf-b1a6-f185d359631c", "type": "Rtsp", "url": "/home/vst/vst_release/streamer_videos/its_short2.mp4", "vodUrl": "/home/vst/vst_release/streamer_videos/its_short2.mp4", } ] }, ] MOCK_TIMELINES_RESPONSE = { "24c5a7d6-39ce-442e-abf0-430f036b7a3d": [ {"endTime": "2025-12-18T07:20:11.332Z", "startTime": "2025-12-18T07:19:59.332Z"} ], "490bd636-32c3-4bcf-b1a6-f185d359631c": [ {"endTime": "2025-01-01T00:00:12.000Z", "startTime": "2025-01-01T00:00:00.000Z"} ], } class TestVSTError: """Test VSTError exception class.""" def test_vst_error_is_exception(self): error = VSTError("Test error message") assert isinstance(error, Exception) def test_vst_error_message(self): error = VSTError("Custom error message") assert str(error) == "Custom error message" def test_vst_error_raise_and_catch(self): with pytest.raises(VSTError, match="Test error"): raise VSTError("Test error") def create_mock_response(status: int, text_data: str): """Helper to create a mock aiohttp response.""" mock_response = AsyncMock() mock_response.status = status mock_response.text = AsyncMock(return_value=text_data) mock_response.__aenter__ = AsyncMock(return_value=mock_response) mock_response.__aexit__ = AsyncMock(return_value=None) return mock_response def create_mock_session(mock_response): """Helper to create a mock aiohttp ClientSession.""" mock_session = MagicMock() mock_session.get = MagicMock(return_value=mock_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) return mock_session async def no_retry_generator(*_args, **_kwargs): """A generator that yields once without retry logic.""" class NoRetryContext: def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): return False # Don't suppress exceptions yield NoRetryContext() class TestGetNameToStreamIdMap: """Test get_name_to_stream_id_map function.""" @pytest.mark.asyncio async def test_successful_mapping(self): """Test successful retrieval of stream ID mapping.""" mock_response = create_mock_response(200, json.dumps(MOCK_STREAMS_RESPONSE)) mock_session = create_mock_session(mock_response) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), ): result = await get_name_to_stream_id_map("http://localhost:30888") assert "carryingcomputer_1" in result assert result["carryingcomputer_1"] == "24c5a7d6-39ce-442e-abf0-430f036b7a3d" assert "its_short2" in result assert result["its_short2"] == "490bd636-32c3-4bcf-b1a6-f185d359631c" @pytest.mark.asyncio async def test_handles_trailing_slash_in_url(self): """Test that trailing slashes in base URL are handled correctly.""" mock_response = create_mock_response(200, json.dumps(MOCK_STREAMS_RESPONSE)) mock_session = create_mock_session(mock_response) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), ): result = await get_name_to_stream_id_map("http://localhost:30888/") assert len(result) == 2 @pytest.mark.asyncio async def test_non_200_status_raises_error(self): """Test that non-200 status raises RuntimeError.""" mock_response = create_mock_response(500, "Internal Server Error") mock_session = create_mock_session(mock_response) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), pytest.raises(RuntimeError, match="VST streams API returned status 500"), ): await get_name_to_stream_id_map("http://localhost:30888") @pytest.mark.asyncio async def test_empty_response(self): """Test handling of empty response.""" mock_response = create_mock_response(200, "[]") mock_session = create_mock_session(mock_response) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), ): result = await get_name_to_stream_id_map("http://localhost:30888") assert result == {} class TestGetTimeline: """Test get_timeline function.""" @pytest.mark.asyncio async def test_successful_timeline_retrieval(self): """Test successful retrieval of timeline data.""" mock_response = create_mock_response(200, json.dumps(MOCK_TIMELINES_RESPONSE)) mock_session = create_mock_session(mock_response) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), ): start_time, end_time = await get_timeline("24c5a7d6-39ce-442e-abf0-430f036b7a3d", "http://localhost:30888") assert start_time == "2025-12-18T07:19:59.332Z" assert end_time == "2025-12-18T07:20:11.332Z" @pytest.mark.asyncio async def test_timeline_with_vst_suffix_in_url(self): """Test that /vst suffix is properly removed from base URL.""" mock_response = create_mock_response(200, json.dumps(MOCK_TIMELINES_RESPONSE)) mock_session = create_mock_session(mock_response) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), ): start_time, end_time = await get_timeline( "490bd636-32c3-4bcf-b1a6-f185d359631c", "http://localhost:30888/vst" ) assert start_time == "2025-01-01T00:00:00.000Z" assert end_time == "2025-01-01T00:00:12.000Z" @pytest.mark.asyncio async def test_timeline_not_found_and_stream_id_not_found_raises_vst_error(self): """Test that missing timeline and sensor name not found raises VSTError. When timeline is not found, get_timeline tries to convert sensor name to stream ID. If that also fails (sensor name not in mapping), VSTError is raised. """ # Mock responses for both timelines and streams APIs mock_timelines_response = create_mock_response(200, json.dumps(MOCK_TIMELINES_RESPONSE)) mock_streams_response = create_mock_response(200, json.dumps(MOCK_STREAMS_RESPONSE)) # Create a session that returns different responses for different URLs call_count = [0] def get_side_effect(*_args, **_kwargs): call_count[0] += 1 # First call is for timelines, subsequent calls are for streams if call_count[0] == 1: return mock_timelines_response return mock_streams_response mock_session = MagicMock() mock_session.get = MagicMock(side_effect=get_side_effect) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), pytest.raises(VSTError), ): await get_timeline("non-existent-stream-id", "http://localhost:30888") @pytest.mark.asyncio async def test_timeline_with_sensor_name_converts_to_stream_id(self): """Test that sensor name is converted to stream ID when timeline not found initially. When timeline lookup fails with a sensor name, get_timeline should: 1. Try to convert sensor name to stream ID 2. Retry timeline lookup with the converted stream ID """ mock_timelines_response = create_mock_response(200, json.dumps(MOCK_TIMELINES_RESPONSE)) mock_streams_response = create_mock_response(200, json.dumps(MOCK_STREAMS_RESPONSE)) # Track calls to return appropriate responses call_count = [0] def get_side_effect(*_args, **_kwargs): call_count[0] += 1 # First call is for timelines (with sensor name - no timeline found) # Second call is for streams API # Third call is for timelines again (with stream ID - success) if call_count[0] == 1 or call_count[0] == 3: return mock_timelines_response return mock_streams_response mock_session = MagicMock() mock_session.get = MagicMock(side_effect=get_side_effect) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), ): # Use sensor name "carryingcomputer_1" which maps to stream ID "24c5a7d6-39ce-442e-abf0-430f036b7a3d" start_time, end_time = await get_timeline("carryingcomputer_1", "http://localhost:30888") assert start_time == "2025-12-18T07:19:59.332Z" assert end_time == "2025-12-18T07:20:11.332Z" @pytest.mark.asyncio async def test_timeline_non_200_status_raises_error(self): """Test that non-200 status raises VSTError (wrapping RuntimeError).""" mock_response = create_mock_response(404, "Not Found") mock_session = create_mock_session(mock_response) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), pytest.raises(VSTError, match="VST timelines API returned status 404"), ): await get_timeline("stream-id", "http://localhost:30888") @pytest.mark.asyncio async def test_timeline_uses_env_default(self): """Test that VST_BASE_URL environment variable is used as default.""" mock_response = create_mock_response(200, json.dumps(MOCK_TIMELINES_RESPONSE)) mock_session = create_mock_session(mock_response) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), patch("vss_agents.tools.vst.utils.create_retry_strategy", side_effect=no_retry_generator), patch.dict("os.environ", {"VST_BASE_URL": "http://env-vst:30888"}), ): start_time, _end_time = await get_timeline("24c5a7d6-39ce-442e-abf0-430f036b7a3d") assert start_time == "2025-12-18T07:19:59.332Z" class TestValidateVideoUrl: """Test validate_video_url function.""" @pytest.mark.asyncio async def test_successful_head_validation(self): """Test successful validation with HEAD request.""" mock_response = AsyncMock() mock_response.status = 200 mock_response.headers = {"content-type": "video/mp4", "content-length": "1024000"} mock_response.__aenter__ = AsyncMock(return_value=mock_response) mock_response.__aexit__ = AsyncMock(return_value=None) mock_session = MagicMock() mock_session.head = MagicMock(return_value=mock_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) with patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session): # Function returns None on success (no exception raised) await validate_video_url("http://example.com/video.mp4") @pytest.mark.asyncio async def test_head_fails_get_succeeds(self): """Test fallback to GET when HEAD fails.""" mock_head_response = AsyncMock() mock_head_response.status = 405 # Method not allowed mock_head_response.__aenter__ = AsyncMock(return_value=mock_head_response) mock_head_response.__aexit__ = AsyncMock(return_value=None) mock_get_response = AsyncMock() mock_get_response.status = 206 # Partial Content mock_get_response.headers = {"content-type": "video/mp4", "content-length": "1024"} mock_get_response.__aenter__ = AsyncMock(return_value=mock_get_response) mock_get_response.__aexit__ = AsyncMock(return_value=None) mock_session = MagicMock() mock_session.head = MagicMock(return_value=mock_head_response) mock_session.get = MagicMock(return_value=mock_get_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) with patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session): # The function should not raise and complete successfully await validate_video_url("http://example.com/video.mp4") @pytest.mark.asyncio async def test_both_head_and_get_fail_raises_error(self): """Test that VSTError is raised when both HEAD and GET fail.""" mock_head_response = AsyncMock() mock_head_response.status = 500 mock_head_response.__aenter__ = AsyncMock(return_value=mock_head_response) mock_head_response.__aexit__ = AsyncMock(return_value=None) mock_get_response = AsyncMock() mock_get_response.status = 500 mock_get_response.__aenter__ = AsyncMock(return_value=mock_get_response) mock_get_response.__aexit__ = AsyncMock(return_value=None) mock_session = MagicMock() mock_session.head = MagicMock(return_value=mock_head_response) mock_session.get = MagicMock(return_value=mock_get_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) with ( patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session), pytest.raises(VSTError, match="URL validation failed"), ): await validate_video_url("http://example.com/video.mp4") @pytest.mark.asyncio async def test_warns_on_non_video_content_type(self): """Test that non-video content type logs a warning but succeeds.""" mock_response = AsyncMock() mock_response.status = 200 mock_response.headers = {"content-type": "application/octet-stream", "content-length": "1024000"} mock_response.__aenter__ = AsyncMock(return_value=mock_response) mock_response.__aexit__ = AsyncMock(return_value=None) mock_session = MagicMock() mock_session.head = MagicMock(return_value=mock_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) with patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session): # Function returns None on success (no exception raised) await validate_video_url("http://example.com/video.mp4") @pytest.mark.asyncio async def test_warns_on_zero_content_length(self): """Test that zero content length logs a warning but succeeds.""" mock_response = AsyncMock() mock_response.status = 200 mock_response.headers = {"content-type": "video/mp4", "content-length": "0"} mock_response.__aenter__ = AsyncMock(return_value=mock_response) mock_response.__aexit__ = AsyncMock(return_value=None) mock_session = MagicMock() mock_session.head = MagicMock(return_value=mock_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) with patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session): # Function returns None on success (no exception raised) await validate_video_url("http://example.com/video.mp4") @pytest.mark.asyncio async def test_custom_timeout(self): """Test that custom timeout is respected.""" mock_response = AsyncMock() mock_response.status = 200 mock_response.headers = {"content-type": "video/mp4", "content-length": "1024000"} mock_response.__aenter__ = AsyncMock(return_value=mock_response) mock_response.__aexit__ = AsyncMock(return_value=None) mock_session = MagicMock() mock_session.head = MagicMock(return_value=mock_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) with patch("vss_agents.tools.vst.utils.aiohttp.ClientSession", return_value=mock_session) as mock_cls: await validate_video_url("http://example.com/video.mp4", timeout=60) # Verify ClientSession was called mock_cls.assert_called_once() ================================================ FILE: agent/tests/unit_test/tools/vst/test_video_clip.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for VST video_clip module.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.video_clip import VSTVideoClipConfig from vss_agents.tools.vst.video_clip import VSTVideoClipISOInput from vss_agents.tools.vst.video_clip import VSTVideoClipOffsetInput from vss_agents.tools.vst.video_clip import VSTVideoClipOutput class TestVSTVideoClipConfig: """Test VSTVideoClipConfig model.""" def test_valid_config(self): """Test creating config with valid URLs.""" config = VSTVideoClipConfig(vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://localhost:30888") assert config.vst_internal_url == "http://10.0.0.1:30888" assert config.vst_external_url == "http://localhost:30888" assert config.overlay_config is False assert config.time_format == "offset" def test_config_with_overlay(self): """Test config with overlay enabled.""" config = VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://localhost:30888", overlay_config=True, ) assert config.overlay_config is True def test_config_with_time_format_iso(self): """Test config with ISO timestamp format.""" config = VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://localhost:30888", time_format="iso", ) assert config.time_format == "iso" def test_config_with_time_format_offset(self): """Test config with offset timestamp format (default).""" config = VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://localhost:30888", time_format="offset", ) assert config.time_format == "offset" def test_config_with_host_ip_placeholder(self): """Test config with HOST_IP placeholder.""" config = VSTVideoClipConfig( vst_internal_url="http://${INTERNAL_IP}:30888", vst_external_url="http://${HOST_IP}:30888" ) assert config.vst_internal_url == "http://${INTERNAL_IP}:30888" assert config.vst_external_url == "http://${HOST_IP}:30888" def test_config_with_trailing_slash(self): """Test config with trailing slash in URL.""" config = VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888/", vst_external_url="http://localhost:30888/" ) assert config.vst_internal_url == "http://10.0.0.1:30888/" assert config.vst_external_url == "http://localhost:30888/" def test_missing_vst_urls_raises(self): """Test that missing URLs raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipConfig() # type: ignore def test_config_description(self): """Test that config has proper field description.""" field_info = VSTVideoClipConfig.model_fields["vst_internal_url"] assert "internal" in field_info.description.lower() # type: ignore class TestVSTVideoClipOffsetInput: """Test VSTVideoClipOffsetInput model including model_validator.""" def test_valid_input_with_times(self): """Test creating input with valid sensor_id and time range.""" input_data = VSTVideoClipOffsetInput(sensor_id="carryingcomputer_1", start_time=0.0, end_time=10.0) assert input_data.sensor_id == "carryingcomputer_1" assert input_data.start_time == 0.0 assert input_data.end_time == 10.0 def test_valid_input_without_times(self): """Test creating input with only sensor_id (optional times).""" input_data = VSTVideoClipOffsetInput(sensor_id="carryingcomputer_1") assert input_data.sensor_id == "carryingcomputer_1" assert input_data.start_time is None assert input_data.end_time is None def test_valid_input_with_object_ids(self): """Test creating input with object_ids.""" input_data = VSTVideoClipOffsetInput( sensor_id="camera-001", start_time=0.0, end_time=20.0, object_ids=["obj-1", "obj-2"], ) assert input_data.object_ids == ["obj-1", "obj-2"] def test_input_object_ids_default_none(self): """Test that object_ids defaults to None.""" input_data = VSTVideoClipOffsetInput(sensor_id="camera-001") assert input_data.object_ids is None def test_input_with_uuid_sensor_id(self): """Test input with UUID-style sensor_id.""" input_data = VSTVideoClipOffsetInput(sensor_id="24c5a7d6-39ce-442e-abf0-430f036b7a3d") assert input_data.sensor_id == "24c5a7d6-39ce-442e-abf0-430f036b7a3d" def test_input_with_only_start_time(self): """Test input with only start_time (end_time is None).""" input_data = VSTVideoClipOffsetInput(sensor_id="test_video", start_time=5.0) assert input_data.start_time == 5.0 assert input_data.end_time is None def test_input_with_only_end_time(self): """Test input with only end_time (start_time is None).""" input_data = VSTVideoClipOffsetInput(sensor_id="test_video", end_time=10.0) assert input_data.start_time is None assert input_data.end_time == 10.0 def test_missing_sensor_id_raises(self): """Test that missing sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipOffsetInput(start_time=0.0, end_time=10.0) # type: ignore def test_empty_sensor_id_raises(self): """Test that empty sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipOffsetInput(sensor_id="", start_time=0.0, end_time=10.0) def test_negative_start_time_raises(self): """Test that negative start_time raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipOffsetInput(sensor_id="test_video", start_time=-1.0, end_time=10.0) def test_negative_end_time_raises(self): """Test that negative end_time raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipOffsetInput(sensor_id="test_video", start_time=0.0, end_time=-5.0) def test_start_time_equals_end_time_raises(self): """Test that start_time equal to end_time raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipOffsetInput(sensor_id="test_video", start_time=5.0, end_time=5.0) def test_start_time_greater_than_end_time_raises(self): """Test that start_time greater than end_time raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipOffsetInput(sensor_id="test_video", start_time=10.0, end_time=5.0) def test_validator_with_integer_times(self): """Test that model_validator handles integer times.""" input_data = VSTVideoClipOffsetInput(sensor_id="test_video", start_time=0, end_time=10) assert isinstance(input_data.start_time, float) assert isinstance(input_data.end_time, float) def test_input_descriptions(self): """Test that input fields have proper descriptions.""" sensor_field = VSTVideoClipOffsetInput.model_fields["sensor_id"] start_field = VSTVideoClipOffsetInput.model_fields["start_time"] end_field = VSTVideoClipOffsetInput.model_fields["end_time"] assert sensor_field.description is not None assert start_field.description is not None assert end_field.description is not None assert "name" in sensor_field.description.lower() or "stream" in sensor_field.description.lower() assert "time" in start_field.description.lower() assert "time" in end_field.description.lower() class TestVSTVideoClipISOInput: """Test VSTVideoClipISOInput model.""" def test_valid_input_with_iso_timestamps(self): """Test creating input with ISO 8601 timestamps.""" input_data = VSTVideoClipISOInput( sensor_id="camera-001", start_time="2025-08-25T03:05:55.752Z", end_time="2025-08-25T03:06:15.752Z", ) assert input_data.sensor_id == "camera-001" assert input_data.start_time == "2025-08-25T03:05:55.752Z" assert input_data.end_time == "2025-08-25T03:06:15.752Z" def test_valid_input_without_times(self): """Test creating input with only sensor_id.""" input_data = VSTVideoClipISOInput(sensor_id="camera-001") assert input_data.start_time is None assert input_data.end_time is None def test_valid_input_with_object_ids(self): """Test creating input with object_ids.""" input_data = VSTVideoClipISOInput( sensor_id="camera-001", start_time="2025-08-25T03:05:55.752Z", end_time="2025-08-25T03:06:15.752Z", object_ids=["obj-1", "obj-2"], ) assert input_data.object_ids == ["obj-1", "obj-2"] def test_missing_sensor_id_raises(self): """Test that missing sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipISOInput(start_time="2025-08-25T03:05:55.752Z", end_time="2025-08-25T03:06:15.752Z") # type: ignore def test_empty_sensor_id_raises(self): """Test that empty sensor_id raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipISOInput(sensor_id="", start_time="2025-08-25T03:05:55.752Z") def test_input_descriptions(self): """Test that input fields have proper descriptions.""" sensor_field = VSTVideoClipISOInput.model_fields["sensor_id"] start_field = VSTVideoClipISOInput.model_fields["start_time"] end_field = VSTVideoClipISOInput.model_fields["end_time"] assert sensor_field.description is not None assert start_field.description is not None assert end_field.description is not None assert "name" in sensor_field.description.lower() or "stream" in sensor_field.description.lower() # type: ignore assert "iso" in start_field.description.lower() or "8601" in start_field.description assert "iso" in end_field.description.lower() or "8601" in end_field.description class TestVSTVideoClipOutput: """Test VSTVideoClipOutput model.""" def test_valid_output(self): """Test creating output with valid video_url and stream_id.""" output = VSTVideoClipOutput( video_url="http://localhost:30888/video/clip.mp4", stream_id="24c5a7d6-39ce-442e-abf0-430f036b7a3d", ) assert output.video_url == "http://localhost:30888/video/clip.mp4" assert output.stream_id == "24c5a7d6-39ce-442e-abf0-430f036b7a3d" def test_output_with_real_url_format(self): """Test output with URL format from real VST server.""" output = VSTVideoClipOutput( video_url="http://10.0.0.1:30888/vst/api/v1/storage/file/24c5a7d6-39ce-442e-abf0-430f036b7a3d/url?startTime=2025-12-18T07:19:59.332Z&endTime=2025-12-18T07:20:11.332Z", stream_id="24c5a7d6-39ce-442e-abf0-430f036b7a3d", ) assert "24c5a7d6-39ce-442e-abf0-430f036b7a3d" in output.video_url assert output.stream_id == "24c5a7d6-39ce-442e-abf0-430f036b7a3d" def test_missing_video_url_raises(self): """Test that missing video_url raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipOutput(stream_id="24c5a7d6-39ce-442e-abf0-430f036b7a3d") # type: ignore def test_missing_stream_id_raises(self): """Test that missing stream_id raises ValidationError.""" with pytest.raises(ValidationError): VSTVideoClipOutput(video_url="http://example.com/video.mp4") # type: ignore def test_output_json_serializable(self): """Test that output can be serialized to JSON.""" output = VSTVideoClipOutput( video_url="http://example.com/video.mp4", stream_id="test-stream-id", ) json_str = output.model_dump_json() assert "http://example.com/video.mp4" in json_str assert "test-stream-id" in json_str def test_output_descriptions(self): """Test that output fields have proper descriptions.""" video_field = VSTVideoClipOutput.model_fields["video_url"] stream_field = VSTVideoClipOutput.model_fields["stream_id"] assert video_field.description is not None assert stream_field.description is not None assert "URL" in video_field.description or "video" in video_field.description.lower() # type: ignore assert "stream" in stream_field.description.lower() class TestVSTVideoClipOffsetInputEdgeCases: """Test edge cases for VSTVideoClipOffsetInput model_validator.""" def test_very_small_time_difference(self): """Test input with very small time difference.""" input_data = VSTVideoClipOffsetInput(sensor_id="test_video", start_time=0.0, end_time=0.001) assert input_data.start_time is not None assert input_data.end_time is not None assert input_data.start_time < input_data.end_time def test_large_time_values(self): """Test input with large time values.""" input_data = VSTVideoClipOffsetInput(sensor_id="test_video", start_time=0.0, end_time=86400.0) # 24 hours assert input_data.end_time == 86400.0 def test_float_precision(self): """Test input maintains float precision.""" input_data = VSTVideoClipOffsetInput(sensor_id="test_video", start_time=1.123456789, end_time=2.987654321) assert input_data.start_time is not None assert input_data.end_time is not None assert abs(input_data.start_time - 1.123456789) < 1e-9 assert abs(input_data.end_time - 2.987654321) < 1e-9 ================================================ FILE: agent/tests/unit_test/tools/vst/test_video_clip_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for vst.video_clip module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.video_clip import VSTVideoClipConfig from vss_agents.tools.vst.video_clip import VSTVideoClipISOInput from vss_agents.tools.vst.video_clip import VSTVideoClipOffsetInput from vss_agents.tools.vst.video_clip import VSTVideoClipOutput class TestVSTVideoClipConfig: """Test VSTVideoClipConfig model.""" def test_required_fields(self): config = VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", ) assert config.vst_internal_url == "http://10.0.0.1:30888" assert config.vst_external_url == "http://1.2.3.4:30888" assert config.overlay_config is False assert config.time_format == "offset" def test_missing_fields_raises(self): with pytest.raises(ValidationError): VSTVideoClipConfig(vst_internal_url="http://10.0.0.1:30888") def test_overlay_config_enabled(self): config = VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", overlay_config=True, ) assert config.overlay_config is True def test_time_format_iso(self): config = VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", time_format="iso", ) assert config.time_format == "iso" class TestVSTVideoClipOffsetInput: """Test VSTVideoClipOffsetInput model.""" def test_sensor_id_only(self): inp = VSTVideoClipOffsetInput(sensor_id="camera1") assert inp.sensor_id == "camera1" assert inp.start_time is None assert inp.end_time is None def test_with_times(self): inp = VSTVideoClipOffsetInput( sensor_id="camera1", start_time=10.0, end_time=20.0, ) assert inp.start_time == 10.0 assert inp.end_time == 20.0 def test_with_object_ids(self): inp = VSTVideoClipOffsetInput( sensor_id="camera1", start_time=10.0, end_time=20.0, object_ids=["obj-1", "obj-2"], ) assert inp.object_ids == ["obj-1", "obj-2"] def test_empty_sensor_id_raises(self): with pytest.raises(ValidationError): VSTVideoClipOffsetInput(sensor_id="") def test_negative_start_time_raises(self): with pytest.raises(ValueError, match="non-negative"): VSTVideoClipOffsetInput(sensor_id="cam1", start_time=-1.0) def test_negative_end_time_raises(self): with pytest.raises(ValueError, match="non-negative"): VSTVideoClipOffsetInput(sensor_id="cam1", end_time=-1.0) def test_start_after_end_raises(self): with pytest.raises(ValueError, match="before end time"): VSTVideoClipOffsetInput(sensor_id="cam1", start_time=20.0, end_time=10.0) def test_start_equals_end_raises(self): with pytest.raises(ValueError, match="before end time"): VSTVideoClipOffsetInput(sensor_id="cam1", start_time=10.0, end_time=10.0) def test_float_conversion(self): inp = VSTVideoClipOffsetInput(sensor_id="cam1", start_time=5, end_time=15) assert inp.start_time == 5.0 assert inp.end_time == 15.0 class TestVSTVideoClipISOInput: """Test VSTVideoClipISOInput model.""" def test_with_iso_timestamps(self): inp = VSTVideoClipISOInput( sensor_id="camera1", start_time="2025-08-25T03:05:55.752Z", end_time="2025-08-25T03:06:15.752Z", ) assert inp.start_time == "2025-08-25T03:05:55.752Z" assert inp.end_time == "2025-08-25T03:06:15.752Z" def test_sensor_id_only(self): inp = VSTVideoClipISOInput(sensor_id="camera1") assert inp.start_time is None assert inp.end_time is None def test_empty_sensor_id_raises(self): with pytest.raises(ValidationError): VSTVideoClipISOInput(sensor_id="") class TestVSTVideoClipOutput: """Test VSTVideoClipOutput model.""" def test_valid(self): output = VSTVideoClipOutput( video_url="http://example.com/video.mp4", stream_id="stream-uuid", ) assert output.video_url == "http://example.com/video.mp4" assert output.stream_id == "stream-uuid" def test_missing_fields_raises(self): with pytest.raises(ValidationError): VSTVideoClipOutput(video_url="http://example.com/video.mp4") ================================================ FILE: agent/tests/unit_test/tools/vst/test_video_clip_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vst.video_clip inner function.""" import json from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.tools.vst.video_clip import VSTVideoClipConfig from vss_agents.tools.vst.video_clip import VSTVideoClipISOInput from vss_agents.tools.vst.video_clip import VSTVideoClipOffsetInput from vss_agents.tools.vst.video_clip import VSTVideoClipOutput from vss_agents.tools.vst.video_clip import get_video_url from vss_agents.tools.vst.video_clip import vst_video_clip class TestGetVideoUrl: """Test get_video_url function.""" @pytest.mark.asyncio async def test_get_video_url_full_video(self): """Test getting full video URL without time range.""" with patch("vss_agents.tools.vst.video_clip.get_timeline", new_callable=AsyncMock) as mock_timeline: mock_timeline.return_value = ("2025-01-01T00:00:00.000Z", "2025-01-01T01:00:00.000Z") mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"videoUrl": "http://vst/video.mp4"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.video_clip.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.video_clip.create_retry_strategy") as mock_retry: # Simple retry that just yields once async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() result = await get_video_url("stream1", vst_internal_url="http://vst:30888") assert result == "http://vst/video.mp4" @pytest.mark.asyncio async def test_get_video_url_with_time_range(self): """Test getting video URL with start and end time.""" with patch("vss_agents.tools.vst.video_clip.get_timeline", new_callable=AsyncMock) as mock_timeline: mock_timeline.return_value = ("2025-01-01T00:00:00.000Z", "2025-01-01T01:00:00.000Z") mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"videoUrl": "http://vst/clip.mp4"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.video_clip.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.video_clip.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() result = await get_video_url( "stream1", start_time=10.0, end_time=20.0, vst_internal_url="http://vst:30888" ) assert result == "http://vst/clip.mp4" @pytest.mark.asyncio async def test_get_video_url_with_iso_timestamps(self): """Test getting video URL with ISO 8601 timestamps.""" mock_response = MagicMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value=json.dumps({"videoUrl": "http://vst/clip.mp4"})) mock_response_cm = AsyncMock() mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_response_cm.__aexit__ = AsyncMock(return_value=False) mock_session = MagicMock() mock_session.get.return_value = mock_response_cm mock_session_cm = AsyncMock() mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) mock_session_cm.__aexit__ = AsyncMock(return_value=False) with patch("vss_agents.tools.vst.video_clip.aiohttp.ClientSession", return_value=mock_session_cm): with patch("vss_agents.tools.vst.video_clip.create_retry_strategy") as mock_retry: async def fake_retry(*args, **kwargs): yield MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock(return_value=False)) mock_retry.return_value = fake_retry() result = await get_video_url( "stream1", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T00:10:00.000Z", vst_internal_url="http://vst:30888", ) assert result == "http://vst/clip.mp4" @pytest.mark.asyncio async def test_get_video_url_invalid_range(self): """Test error when clip end time is before start time.""" with patch("vss_agents.tools.vst.video_clip.get_timeline", new_callable=AsyncMock) as mock_timeline: # 60-second timeline mock_timeline.return_value = ("2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z") # end_time (5s) < start_time (30s) → clip_end < clip_start → ValueError with pytest.raises(ValueError, match="within the stream timeline"): await get_video_url("stream1", start_time=30.0, end_time=5.0, vst_internal_url="http://vst:30888") class TestVSTVideoClipInner: """Test vst_video_clip inner function.""" @pytest.fixture def config(self): return VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", ) @pytest.fixture def config_iso(self): return VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", time_format="iso", ) @pytest.fixture def config_with_overlay(self): return VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", overlay_config=True, ) @pytest.fixture def config_iso_with_overlay(self): return VSTVideoClipConfig( vst_internal_url="http://10.0.0.1:30888", vst_external_url="http://1.2.3.4:30888", overlay_config=True, time_format="iso", ) @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_video_clip_inner(self, config, mock_builder): with patch("vss_agents.tools.vst.video_clip.get_stream_id", new_callable=AsyncMock) as mock_get_id: mock_get_id.return_value = "stream-uuid" with patch("vss_agents.tools.vst.video_clip.get_video_url", new_callable=AsyncMock) as mock_get_url: mock_get_url.return_value = "http://10.0.0.1:30888/vst/video.mp4" with patch("vss_agents.tools.vst.video_clip.validate_video_url", new_callable=AsyncMock): gen = vst_video_clip.__wrapped__(config, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VSTVideoClipOffsetInput(sensor_id="camera1", start_time=10.0, end_time=20.0) result = await inner_fn(inp) assert isinstance(result, VSTVideoClipOutput) assert "1.2.3.4:30888" in result.video_url assert result.stream_id == "stream-uuid" @pytest.mark.asyncio async def test_video_clip_inner_with_iso_timestamps(self, config_iso, mock_builder): """Test video clip with ISO timestamps.""" with patch("vss_agents.tools.vst.video_clip.get_stream_id", new_callable=AsyncMock) as mock_get_id: mock_get_id.return_value = "stream-uuid" with patch("vss_agents.tools.vst.video_clip.get_video_url", new_callable=AsyncMock) as mock_get_url: mock_get_url.return_value = "http://10.0.0.1:30888/vst/video.mp4" with patch("vss_agents.tools.vst.video_clip.validate_video_url", new_callable=AsyncMock): gen = vst_video_clip.__wrapped__(config_iso, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VSTVideoClipISOInput( sensor_id="camera1", start_time="2025-08-25T03:05:55.752Z", end_time="2025-08-25T03:06:15.752Z", ) result = await inner_fn(inp) assert isinstance(result, VSTVideoClipOutput) assert "1.2.3.4:30888" in result.video_url assert result.stream_id == "stream-uuid" @pytest.mark.asyncio async def test_video_clip_uses_correct_input_schema_offset(self, config, mock_builder): """Test that offset mode uses VSTVideoClipOffsetInput schema.""" gen = vst_video_clip.__wrapped__(config, mock_builder) fi = await gen.__anext__() assert fi.input_schema is VSTVideoClipOffsetInput @pytest.mark.asyncio async def test_video_clip_uses_correct_input_schema_iso(self, config_iso, mock_builder): """Test that iso mode uses VSTVideoClipISOInput schema.""" gen = vst_video_clip.__wrapped__(config_iso, mock_builder) fi = await gen.__anext__() assert fi.input_schema is VSTVideoClipISOInput @pytest.mark.asyncio async def test_video_clip_inner_with_object_ids(self, config_iso_with_overlay, mock_builder): """Test video clip with object_ids for overlay bounding boxes.""" with patch("vss_agents.tools.vst.video_clip.get_stream_id", new_callable=AsyncMock) as mock_get_id: mock_get_id.return_value = "stream-uuid" with patch("vss_agents.tools.vst.video_clip.get_video_url", new_callable=AsyncMock) as mock_get_url: mock_get_url.return_value = "http://10.0.0.1:30888/vst/video.mp4" with patch("vss_agents.tools.vst.video_clip.validate_video_url", new_callable=AsyncMock): gen = vst_video_clip.__wrapped__(config_iso_with_overlay, mock_builder) fi = await gen.__anext__() inner_fn = fi.single_fn inp = VSTVideoClipISOInput( sensor_id="camera1", start_time="2025-08-25T03:05:55.752Z", end_time="2025-08-25T03:06:15.752Z", object_ids=["obj-1", "obj-2"], ) result = await inner_fn(inp) assert isinstance(result, VSTVideoClipOutput) assert "1.2.3.4:30888" in result.video_url assert result.stream_id == "stream-uuid" # Verify get_video_url was called with overlay params mock_get_url.assert_called_once_with( "stream-uuid", "2025-08-25T03:05:55.752Z", "2025-08-25T03:06:15.752Z", "http://10.0.0.1:30888", overlay_enabled=True, object_ids=["obj-1", "obj-2"], ) ================================================ FILE: agent/tests/unit_test/tools/vst/test_video_list_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Additional unit tests for vst.video_list module to improve coverage.""" from pydantic import ValidationError import pytest from vss_agents.tools.vst.video_list import VSTVideoListConfig from vss_agents.tools.vst.video_list import VSTVideoListInput from vss_agents.tools.vst.video_list import VSTVideoListOutput class TestVSTVideoListConfig: """Test VSTVideoListConfig model.""" def test_required_fields(self): config = VSTVideoListConfig(vst_internal_url="http://10.0.0.1:30888") assert config.vst_internal_url == "http://10.0.0.1:30888" def test_missing_url_raises(self): with pytest.raises(ValidationError): VSTVideoListConfig() class TestVSTVideoListInput: """Test VSTVideoListInput model.""" def test_empty_input(self): inp = VSTVideoListInput() assert inp is not None class TestVSTVideoListOutput: """Test VSTVideoListOutput model.""" def test_valid(self): output = VSTVideoListOutput( video_list=[ {"name": "video1.mp4", "duration": 60.0}, {"name": "video2.mp4", "duration": 120.0}, ] ) assert len(output.video_list) == 2 assert output.video_list[0]["name"] == "video1.mp4" def test_empty_list(self): output = VSTVideoListOutput(video_list=[]) assert output.video_list == [] def test_missing_field_raises(self): with pytest.raises(ValidationError): VSTVideoListOutput() ================================================ FILE: agent/tests/unit_test/utils/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vss_agents.utils package.""" ================================================ FILE: agent/tests/unit_test/utils/test_asyncmixin.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/asyncmixin.py.""" import pytest from vss_agents.utils.asyncmixin import AsyncMixin class TestAsyncMixin: """Tests for AsyncMixin class.""" @pytest.mark.asyncio async def test_async_initialization(self): """Test async initialization using await.""" class TestClass(AsyncMixin): async def __ainit__(self, value): self.value = value instance = await TestClass(42) assert instance.value == 42 assert instance.async_initialized is True @pytest.mark.asyncio async def test_stored_args(self): """Test that constructor args are stored and passed to __ainit__.""" class TestClass(AsyncMixin): async def __ainit__(self, a, b, c=None): self.a = a self.b = b self.c = c instance = await TestClass(1, 2, c=3) assert instance.a == 1 assert instance.b == 2 assert instance.c == 3 @pytest.mark.asyncio async def test_async_initialized_flag(self): """Test async_initialized flag is False before await.""" class TestClass(AsyncMixin): async def __ainit__(self): pass obj = TestClass() assert obj.async_initialized is False instance = await obj assert instance.async_initialized is True @pytest.mark.asyncio async def test_await_returns_self(self): """Test that awaiting returns the instance.""" class TestClass(AsyncMixin): async def __ainit__(self): pass obj = TestClass() result = await obj assert result is obj @pytest.mark.asyncio async def test_async_init_with_no_params(self): """Test class with no parameters.""" class TestClass(AsyncMixin): async def __ainit__(self): self.initialized = True instance = await TestClass() assert instance.initialized is True @pytest.mark.asyncio async def test_double_await_raises(self): """Test that awaiting twice raises assertion error.""" class TestClass(AsyncMixin): async def __ainit__(self): pass instance = await TestClass() # Awaiting again should raise AssertionError with pytest.raises(AssertionError): await instance @pytest.mark.asyncio async def test_async_init_exception(self): """Test that exceptions in __ainit__ propagate.""" class TestClass(AsyncMixin): async def __ainit__(self): raise ValueError("Init failed") with pytest.raises(ValueError, match="Init failed"): await TestClass() @pytest.mark.asyncio async def test_async_init_with_async_operations(self): """Test __ainit__ with actual async operations.""" import asyncio class TestClass(AsyncMixin): async def __ainit__(self, delay): await asyncio.sleep(delay) self.completed = True instance = await TestClass(0.001) assert instance.completed is True ================================================ FILE: agent/tests/unit_test/utils/test_file_mapping.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/file_mapping.py.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.utils.file_mapping import FileMapping from vss_agents.utils.file_mapping import StorageType from vss_agents.utils.file_mapping import VideoFileInfo from vss_agents.utils.file_mapping import resolve_video_file class TestStorageType: """Tests for StorageType enum.""" def test_storage_type_values(self): """Test StorageType enum values.""" assert StorageType.VST.value == "vst" assert StorageType.VSS.value == "vss" assert StorageType.LOCAL.value == "local" class TestVideoFileInfo: """Tests for VideoFileInfo dataclass.""" def test_create_video_file_info(self): """Test creating VideoFileInfo.""" info = VideoFileInfo( filename="test.mp4", storage_type=StorageType.VST, storage_id="vst-123", duration=120.5, sensor_id="sensor-001", timestamp=1234567890, local_path=None, ) assert info.filename == "test.mp4" assert info.storage_type == StorageType.VST assert info.storage_id == "vst-123" assert info.duration == 120.5 assert info.sensor_id == "sensor-001" assert info.timestamp == 1234567890 def test_video_file_info_defaults(self): """Test VideoFileInfo with default values.""" info = VideoFileInfo( filename="test.mp4", storage_type=StorageType.LOCAL, storage_id="local-id", duration=60.0, ) assert info.sensor_id is None assert info.timestamp is None assert info.local_path is None class TestFileMapping: """Tests for FileMapping class.""" def test_init(self): """Test FileMapping initialization.""" fm = FileMapping() assert fm._filename_to_info == {} assert fm._vss_filename_to_id == {} assert fm._vst_filename_to_id == {} def test_add_vst_files(self): """Test adding VST file mappings.""" fm = FileMapping() vst_data = { "vst-123": { "filename": "camera1.mp4", "duration": 120.0, "sensor_id": "sensor-001", "timestamp": 1234567890, }, "vst-456": { "filename": "camera2.mp4", "duration": 180.0, }, } fm.add_vst_files(vst_data) assert fm.has_vst_file("camera1.mp4") assert fm.has_vst_file("camera2.mp4") assert fm.get_vst_id("camera1.mp4") == "vst-123" assert fm.get_vst_id("camera2.mp4") == "vst-456" def test_add_vss_files(self): """Test adding VSS file mappings.""" fm = FileMapping() vss_data = { "vss-123": "video1.mp4", "vss-456": "video2.mp4", } fm.add_vss_files(vss_data) assert fm.has_vss_file("video1.mp4") assert fm.has_vss_file("video2.mp4") assert fm.get_vss_id("video1.mp4") == "vss-123" assert fm.get_vss_id("video2.mp4") == "vss-456" def test_add_local_files(self): """Test adding local file mappings.""" fm = FileMapping() local_data = { "local1.mp4": { "filename": "local1.mp4", "duration": 60.0, "full_path": "/videos/local1.mp4", }, } fm.add_local_files(local_data) info = fm.get_file_info("local1.mp4") assert info is not None assert info.storage_type == StorageType.LOCAL assert info.local_path == "/videos/local1.mp4" def test_get_file_info(self): """Test getting file info.""" fm = FileMapping() fm.add_vst_files( { "vst-123": { "filename": "test.mp4", "duration": 100.0, } } ) info = fm.get_file_info("test.mp4") assert info is not None assert info.filename == "test.mp4" assert info.storage_type == StorageType.VST def test_get_file_info_not_found(self): """Test getting file info for nonexistent file.""" fm = FileMapping() info = fm.get_file_info("nonexistent.mp4") assert info is None def test_get_storage_type(self): """Test getting storage type.""" fm = FileMapping() fm.add_vst_files( { "vst-123": { "filename": "vst-file.mp4", "duration": 100.0, } } ) assert fm.get_storage_type("vst-file.mp4") == StorageType.VST assert fm.get_storage_type("nonexistent.mp4") is None def test_get_all_filenames(self): """Test getting all filenames.""" fm = FileMapping() fm.add_vst_files( { "vst-1": {"filename": "a.mp4", "duration": 60.0}, "vst-2": {"filename": "b.mp4", "duration": 60.0}, } ) filenames = fm.get_all_filenames() assert "a.mp4" in filenames assert "b.mp4" in filenames def test_get_files_by_storage_type(self): """Test getting files by storage type.""" fm = FileMapping() fm.add_vst_files( { "vst-1": {"filename": "vst.mp4", "duration": 60.0}, } ) fm.add_local_files( { "local.mp4": {"filename": "local.mp4", "duration": 60.0, "full_path": "/local.mp4"}, } ) vst_files = fm.get_files_by_storage_type(StorageType.VST) local_files = fm.get_files_by_storage_type(StorageType.LOCAL) assert "vst.mp4" in vst_files assert "local.mp4" in local_files assert len(vst_files) == 1 assert len(local_files) == 1 def test_clear(self): """Test clearing all mappings.""" fm = FileMapping() fm.add_vst_files({"vst-1": {"filename": "test.mp4", "duration": 60.0}}) fm.clear() assert fm.get_all_filenames() == [] assert not fm.has_vst_file("test.mp4") def test_has_vst_file_false(self): """Test has_vst_file returns False for nonexistent file.""" fm = FileMapping() assert not fm.has_vst_file("nonexistent.mp4") def test_has_vss_file_false(self): """Test has_vss_file returns False for nonexistent file.""" fm = FileMapping() assert not fm.has_vss_file("nonexistent.mp4") class TestResolveVideoFile: """Tests for resolve_video_file function.""" @pytest.mark.asyncio async def test_resolve_local_file(self, tmp_path): """Test resolving a local video file.""" # Create a temp file video_file = tmp_path / "test.mp4" video_file.write_text("fake video content") # Add to file mapping test_mapping = FileMapping() test_mapping.add_local_files( { "test.mp4": { "filename": "test.mp4", "duration": 60.0, "full_path": str(video_file), } } ) with patch("vss_agents.utils.file_mapping.file_mapping", test_mapping): path, needs_cleanup = await resolve_video_file("test.mp4", 0.0, 10.0) assert path == str(video_file) assert not needs_cleanup @pytest.mark.asyncio async def test_resolve_file_not_found(self): """Test resolving nonexistent file.""" test_mapping = FileMapping() with patch("vss_agents.utils.file_mapping.file_mapping", test_mapping): with pytest.raises(ValueError, match="not found"): await resolve_video_file("nonexistent.mp4", 0.0, 10.0) @pytest.mark.asyncio async def test_resolve_vst_file_no_tool(self): """Test resolving VST file without download tool raises error.""" test_mapping = FileMapping() test_mapping.add_vst_files( { "vst-123": { "filename": "vst-file.mp4", "duration": 60.0, } } ) with patch("vss_agents.utils.file_mapping.file_mapping", test_mapping): with pytest.raises(ValueError, match="VST download tool not available"): await resolve_video_file("vst-file.mp4", 0.0, 10.0, vst_download_tool=None) @pytest.mark.asyncio async def test_resolve_local_file_not_exists(self): """Test resolving local file that doesn't exist on disk.""" test_mapping = FileMapping() test_mapping.add_local_files( { "missing.mp4": { "filename": "missing.mp4", "duration": 60.0, "full_path": "/nonexistent/path/missing.mp4", } } ) with patch("vss_agents.utils.file_mapping.file_mapping", test_mapping): with pytest.raises(ValueError, match="Local file not found"): await resolve_video_file("missing.mp4", 0.0, 10.0) @pytest.mark.asyncio async def test_resolve_vst_file_with_tool(self): """Test resolving VST file with download tool (covers lines 216-239).""" test_mapping = FileMapping() test_mapping.add_vst_files( { "vst-123": { "filename": "vst-video.mp4", "duration": 60.0, } } ) # Mock the download tool mock_download_tool = AsyncMock() mock_result = MagicMock() mock_result.local_file_path = "/tmp/downloaded_clip.mp4" mock_download_tool.ainvoke = AsyncMock(return_value=mock_result) with patch("vss_agents.utils.file_mapping.file_mapping", test_mapping): with patch("tempfile.mkdtemp", return_value="/tmp/vst_clip_test"): path, needs_cleanup = await resolve_video_file( "vst-video.mp4", 0.0, 10.0, vst_download_tool=mock_download_tool ) assert path == "/tmp/downloaded_clip.mp4" assert needs_cleanup is True # Verify the download was called with correct parameters mock_download_tool.ainvoke.assert_called_once() call_input = mock_download_tool.ainvoke.call_args[1]["input"] assert call_input["video_id"] == "vst-123" assert call_input["start_time"] == 0 # 0.0 * 1000 assert call_input["end_time"] == 10000 # 10.0 * 1000 @pytest.mark.asyncio async def test_resolve_vss_file_not_implemented(self): """Test resolving VSS file raises NotImplementedError (covers lines 248-249).""" test_mapping = FileMapping() test_mapping.add_vss_files( { "vss-123": "vss-video.mp4", } ) with patch("vss_agents.utils.file_mapping.file_mapping", test_mapping): with pytest.raises(NotImplementedError, match="VSS storage type not yet supported"): await resolve_video_file("vss-video.mp4", 0.0, 10.0) ================================================ FILE: agent/tests/unit_test/utils/test_frame_select.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for frame_select module.""" from unittest.mock import MagicMock from unittest.mock import patch import numpy as np import pytest from vss_agents.utils.frame_select import frame_select from vss_agents.utils.frame_select import has_nvidia_gpu class TestFrameSelect: """Test frame_select function.""" def test_invalid_video_path(self): with patch("vss_agents.utils.frame_select.cv2") as mock_cv2: mock_cap = MagicMock() mock_cap.isOpened.return_value = False mock_cv2.VideoCapture.return_value = mock_cap with pytest.raises(ValueError, match="Could not open video"): frame_select("/nonexistent/video.mp4", 0.0, 10.0, 1.0) def test_successful_frame_extraction(self): with patch("vss_agents.utils.frame_select.cv2") as mock_cv2: mock_cap = MagicMock() mock_cap.isOpened.return_value = True mock_cap.get.side_effect = lambda prop: {0: 30.0, 7: 300}[prop] # FPS=30, frames=300 mock_cap.read.return_value = (True, np.zeros((100, 100, 3), dtype=np.uint8)) mock_cv2.VideoCapture.return_value = mock_cap mock_cv2.CAP_PROP_FPS = 0 mock_cv2.CAP_PROP_FRAME_COUNT = 7 mock_cv2.CAP_PROP_POS_FRAMES = 1 mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8)) result = frame_select("/path/video.mp4", 0.0, 2.0, 1.0) assert len(result) > 0 assert isinstance(result[0], str) # base64 string def test_no_frames_selected(self): with patch("vss_agents.utils.frame_select.cv2") as mock_cv2: mock_cap = MagicMock() mock_cap.isOpened.return_value = True mock_cap.get.side_effect = lambda prop: {0: 30.0, 7: 10}[prop] mock_cv2.VideoCapture.return_value = mock_cap mock_cv2.CAP_PROP_FPS = 0 mock_cv2.CAP_PROP_FRAME_COUNT = 7 # start_frame > end_frame → empty range result = frame_select("/path/video.mp4", 100.0, 100.0, 1.0) assert result == [] def test_frame_read_failure(self): with patch("vss_agents.utils.frame_select.cv2") as mock_cv2: mock_cap = MagicMock() mock_cap.isOpened.return_value = True mock_cap.get.side_effect = lambda prop: {0: 30.0, 7: 300}[prop] mock_cap.read.return_value = (False, None) # Read failure mock_cv2.VideoCapture.return_value = mock_cap mock_cv2.CAP_PROP_FPS = 0 mock_cv2.CAP_PROP_FRAME_COUNT = 7 mock_cv2.CAP_PROP_POS_FRAMES = 1 with pytest.raises(RuntimeError, match="Error selecting frames"): frame_select("/path/video.mp4", 0.0, 2.0, 1.0) class TestHasNvidiaGpu: """Test has_nvidia_gpu function.""" def test_no_nvidia_smi(self): with patch("vss_agents.utils.frame_select.shutil.which", return_value=None): assert has_nvidia_gpu() is False def test_nvidia_smi_success(self): with patch("vss_agents.utils.frame_select.shutil.which", return_value="/usr/bin/nvidia-smi"): mock_result = MagicMock() mock_result.returncode = 0 with patch("vss_agents.utils.frame_select.subprocess.run", return_value=mock_result): assert has_nvidia_gpu() is True def test_nvidia_smi_failure(self): with patch("vss_agents.utils.frame_select.shutil.which", return_value="/usr/bin/nvidia-smi"): mock_result = MagicMock() mock_result.returncode = 1 with patch("vss_agents.utils.frame_select.subprocess.run", return_value=mock_result): assert has_nvidia_gpu() is False ================================================ FILE: agent/tests/unit_test/utils/test_markdown_parser.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/markdown_parser.py.""" from vss_agents.utils.markdown_parser import parse_markdown_to_json from vss_agents.utils.markdown_parser import parse_table_or_blocktext class TestParseTable: """Tests for parse_table function.""" def test_parse_simple_table(self): """Test parsing a simple markdown table.""" lines = [ "| Field | Value |", "|-------|-------|", "| Name | John |", "| Age | 30 |", ] result = parse_table_or_blocktext(lines) assert result == {"Name": "John", "Age": "30"} def test_parse_table_with_empty_lines(self): """Test parsing table with empty lines.""" lines = [ "| Field | Value |", "|-------|-------|", "", "| Name | John |", "", ] result = parse_table_or_blocktext(lines) assert result == {"Name": "John"} def test_parse_table_with_bold_text(self): """Test parsing table with bold text (asterisks stripped).""" lines = [ "| **Field** | **Value** |", "|-----------|-----------|", "| **Name** | **John** |", ] result = parse_table_or_blocktext(lines) assert result == {"Name": "John"} def test_parse_table_with_multiple_values(self): """Test parsing table with multiple value columns.""" lines = [ "| Field | Value1 | Value2 |", "|-------|--------|--------|", "| Data | A | B |", ] result = parse_table_or_blocktext(lines) assert result == {"Data": ["A", "B"]} def test_parse_empty_table(self): """Test parsing empty table.""" lines = [] result = parse_table_or_blocktext(lines) assert result == {} def test_parse_table_skip_header(self): """Test that 'Field' header row is skipped.""" lines = [ "| Field | Value |", "|-------|-------|", "| Field | Test |", # This should be skipped ] result = parse_table_or_blocktext(lines) assert result == {} def test_parse_blocktext_with_multiple_paras_time_and_image(self): """Test parsing multi-paragraph block removes time markers and images.""" textblock = [ "[00:05] Incident detected at main gate.", "", "", " Additional context follows.", "", "[01:10] Secondary update after assessment.", ] result = parse_table_or_blocktext([], textblock) expected = "Incident detected at main gate. Additional context follows. Secondary update after assessment." # Ignore spacing differences introduced by line/paragraph joins assert result.replace(" ", "") == expected.replace(" ", "") class TestParseMarkdownToJson: """Tests for parse_markdown_to_json function.""" def test_parse_title(self): """Test parsing markdown title.""" content = "# My Report Title" result = parse_markdown_to_json(content) assert result["title"] == "My Report Title" def test_parse_section_with_table(self): """Test parsing section with table.""" content = """# Report ## Summary | Field | Value | |-------|-------| | Status | Active | | Count | 5 | """ result = parse_markdown_to_json(content) assert result["title"] == "Report" assert result["Summary"] == {"Status": "Active", "Count": "5"} def test_parse_subsections(self): """Test parsing subsections within sections.""" content = """# Report ## Main Section ### Subsection A | Field | Value | |-------|-------| | Item | A | ### Subsection B | Field | Value | |-------|-------| | Item | B | """ result = parse_markdown_to_json(content) assert result["Main Section"]["Subsection A"] == {"Item": "A"} assert result["Main Section"]["Subsection B"] == {"Item": "B"} def test_parse_incident_snapshot_url(self): """Test parsing incident snapshot URL.""" content = """# Report **Incident Snapshot:** [View](http://example.com/snapshot.jpg) """ result = parse_markdown_to_json(content) assert result["Resources"]["Incident Snapshot"] == "http://example.com/snapshot.jpg" def test_parse_incident_video_url(self): """Test parsing incident video URL.""" content = """# Report **Incident Video:** [View](http://example.com/video.mp4) """ result = parse_markdown_to_json(content) assert result["Resources"]["Incident Video"] == "http://example.com/video.mp4" def test_parse_incident_url_plain(self): """Test parsing incident URL on next line (plain format).""" content = """# Report **Incident Snapshot:** http://example.com/snapshot.jpg """ result = parse_markdown_to_json(content) assert result["Resources"]["Incident Snapshot"] == "http://example.com/snapshot.jpg" def test_parse_multiple_sections(self): """Test parsing multiple sections.""" content = """# Report ## Section 1 | Field | Value | |-------|-------| | A | 1 | ## Section 2 | Field | Value | |-------|-------| | B | 2 | """ result = parse_markdown_to_json(content) assert result["Section 1"] == {"A": "1"} assert result["Section 2"] == {"B": "2"} def test_parse_empty_content(self): """Test parsing empty content.""" content = "" result = parse_markdown_to_json(content) assert result == {} def test_parse_content_without_tables(self): """Test parsing content without any tables.""" content = """# Title ## Section Some text without tables. """ result = parse_markdown_to_json(content) assert result["title"] == "Title" def test_parse_subsection_with_table_then_new_section(self): """Test parsing subsection table followed by new section (covers lines 50-52).""" content = """# Report ## Section 1 ### Subsection A | Field | Value | |-------|-------| | Key | Val | ## Section 2 | Field | Value | |-------|-------| | Other | Data | """ result = parse_markdown_to_json(content) assert result["Section 1"]["Subsection A"] == {"Key": "Val"} assert result["Section 2"] == {"Other": "Data"} def test_parse_subsection_without_prior_section_dict(self): """Test parsing subsection when section not yet a dict (covers line 63, 66-67).""" content = """# Report ## Main Section ### Sub A | Field | Value | |-------|-------| | A | 1 | ### Sub B | Field | Value | |-------|-------| | B | 2 | """ result = parse_markdown_to_json(content) assert "Main Section" in result assert result["Main Section"]["Sub A"] == {"A": "1"} assert result["Main Section"]["Sub B"] == {"B": "2"} def test_parse_incident_video_url_plain(self): """Test parsing incident video URL on next line (covers lines 96-100).""" content = """# Report **Incident Video:** https://example.com/video.mp4 """ result = parse_markdown_to_json(content) assert result["Resources"]["Incident Video"] == "https://example.com/video.mp4" def test_parse_subsection_table_at_end(self): """Test parsing subsection table at end of content (covers line 108).""" content = """# Report ## Main ### Details | Field | Value | |-------|-------| | Final | Item | """ result = parse_markdown_to_json(content) assert result["Main"]["Details"] == {"Final": "Item"} def test_section_not_in_result_when_new_section_starts(self): """Test edge case where section not in result when new ## starts (covers line 51).""" # This case requires: subsection table, then new section, where current_section wasn't added content = """# Report ## Section1 ### SubA | Field | Value | |-------|-------| | X | Y | ## Section2 | Field | Value | |-------|-------| | A | B | """ result = parse_markdown_to_json(content) assert result["Section1"]["SubA"] == {"X": "Y"} assert result["Section2"] == {"A": "B"} def test_section_table_without_subsection_when_new_subsection_starts(self): """Test edge case for section table when new subsection starts (covers lines 66-67).""" content = """# Report ## Summary | Field | Value | |-------|-------| | Status | Active | ### Details | Field | Value | |-------|-------| | Item | Value | """ result = parse_markdown_to_json(content) # Parser behavior: first table is processed when ### is encountered # Lines 66-67 make the section a dict if it wasn't before assert "Summary" in result assert "Details" in result["Summary"] assert result["Summary"]["Details"] == {"Item": "Value"} def test_consecutive_subsections_with_tables(self): """Test consecutive subsections with tables (covers line 63).""" content = """# Report ## Parent ### FirstSub | Field | Value | |-------|-------| | A | 1 | ### SecondSub | Field | Value | |-------|-------| | B | 2 | ### ThirdSub | Field | Value | |-------|-------| | C | 3 | """ result = parse_markdown_to_json(content) assert result["Parent"]["FirstSub"] == {"A": "1"} assert result["Parent"]["SecondSub"] == {"B": "2"} assert result["Parent"]["ThirdSub"] == {"C": "3"} ================================================ FILE: agent/tests/unit_test/utils/test_parser.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/parser.py.""" import pytest from vss_agents.utils.parser import ReActOutputParserError from vss_agents.utils.parser import parse_function_calls class TestReActOutputParserError: """Tests for ReActOutputParserError exception.""" def test_error_initialization(self): """Test error initialization with default values.""" error = ReActOutputParserError() assert error.observation is None assert error.missing_action is False assert error.missing_action_input is False assert error.final_answer_and_action is False def test_error_with_observation(self): """Test error with observation message.""" error = ReActOutputParserError(observation="Test observation") assert error.observation == "Test observation" def test_error_flags(self): """Test error with various flags.""" error = ReActOutputParserError( missing_action=True, missing_action_input=True, final_answer_and_action=True, ) assert error.missing_action is True assert error.missing_action_input is True assert error.final_answer_and_action is True class TestParseFunctionCalls: """Tests for parse_function_calls function.""" def test_parse_single_function_no_params(self): """Test parsing single function call without parameters.""" text = "get_data()" result = parse_function_calls(text) assert len(result) == 1 assert result[0]["name"] == "get_data" assert result[0]["args"] == {} def test_parse_single_function_with_string_param(self): """Test parsing function with string parameter.""" text = "video_caption(file_path='video.mp4')" result = parse_function_calls(text) assert len(result) == 1 assert result[0]["name"] == "video_caption" assert result[0]["args"]["file_path"] == "video.mp4" def test_parse_function_with_numeric_params(self): """Test parsing function with numeric parameters.""" text = "process_video(start_timestamp=5, end_timestamp=10)" result = parse_function_calls(text) assert len(result) == 1 assert result[0]["args"]["start_timestamp"] == 5 assert result[0]["args"]["end_timestamp"] == 10 def test_parse_function_with_float_params(self): """Test parsing function with float parameters.""" text = "process_video(start=1.5, end=2.5)" result = parse_function_calls(text) assert result[0]["args"]["start"] == 1.5 assert result[0]["args"]["end"] == 2.5 def test_parse_multiple_functions(self): """Test parsing multiple function calls.""" text = "[func1(a=1), func2(b=2)]" result = parse_function_calls(text) assert len(result) == 2 assert result[0]["name"] == "func1" assert result[1]["name"] == "func2" def test_parse_function_with_list_param(self): """Test parsing function with list parameter.""" text = "process(items=[1, 2, 3])" result = parse_function_calls(text) assert result[0]["args"]["items"] == [1, 2, 3] def test_parse_function_with_dict_param(self): """Test parsing function with dict parameter.""" text = 'process(config={"key": "value"})' result = parse_function_calls(text) assert result[0]["args"]["config"] == {"key": "value"} def test_parse_function_with_double_quotes(self): """Test parsing function with double quoted string.""" text = 'video_caption(file_path="video.mp4")' result = parse_function_calls(text) assert result[0]["args"]["file_path"] == "video.mp4" def test_parse_function_with_nested_quotes(self): """Test parsing function with nested commas in string.""" text = "search(query='hello, world')" result = parse_function_calls(text) assert result[0]["args"]["query"] == "hello, world" def test_parse_function_with_boolean(self): """Test parsing function with boolean parameters.""" text = "process(enabled=True, disabled=False)" result = parse_function_calls(text) assert result[0]["args"]["enabled"] is True assert result[0]["args"]["disabled"] is False def test_parse_function_with_none(self): """Test parsing function with None parameter.""" text = "process(value=None)" result = parse_function_calls(text) assert result[0]["args"]["value"] is None def test_parse_no_function_calls(self): """Test that no function calls raises error.""" text = "This is just plain text" with pytest.raises(ReActOutputParserError): parse_function_calls(text) def test_parse_function_with_whitespace(self): """Test parsing function with extra whitespace.""" text = " process( key = 'value' ) " result = parse_function_calls(text) assert len(result) == 1 assert result[0]["args"]["key"] == "value" def test_parse_function_has_unique_ids(self): """Test that parsed functions have unique IDs.""" text = "[func1(a=1), func2(b=2)]" result = parse_function_calls(text) assert "id" in result[0] assert "id" in result[1] assert result[0]["id"] != result[1]["id"] def test_parse_function_with_nested_parens(self): """Test parsing function with nested parentheses.""" text = "outer(inner=(1, 2))" result = parse_function_calls(text) # The inner tuple should be parsed correctly assert result[0]["name"] == "outer" def test_parse_function_with_mixed_params(self): """Test parsing function with mixed parameter types.""" text = "process(name='test', count=5, items=[1, 2], config={'a': 1})" result = parse_function_calls(text) assert result[0]["args"]["name"] == "test" assert result[0]["args"]["count"] == 5 assert result[0]["args"]["items"] == [1, 2] assert result[0]["args"]["config"] == {"a": 1} def test_parse_function_with_closing_paren_in_tuple(self): """Test parsing function with closing parens in nested tuple (covers line 71).""" text = "outer(data=(1, 2, 3))" result = parse_function_calls(text) assert result[0]["name"] == "outer" # The tuple should be parsed, covering the paren_count -= 1 branch def test_parse_function_with_json_string_fallback(self): """Test parsing function with JSON that looks like dict but fails ast (covers lines 100-105).""" # Create a case where ast.literal_eval works but we test the JSON path too text = 'process(data={"key": "value"})' result = parse_function_calls(text) assert result[0]["args"]["data"] == {"key": "value"} def test_parse_function_with_invalid_json_stays_string(self): """Test that invalid JSON-like strings stay as strings.""" text = "process(data={invalid json})" result = parse_function_calls(text) # Should stay as string since it's not valid JSON or Python literal assert result[0]["args"]["data"] == "{invalid json}" def test_parse_function_with_complex_nested_structures(self): """Test parsing deeply nested structures.""" text = "process(data={'a': [1, 2, {'b': 3}]})" result = parse_function_calls(text) assert result[0]["args"]["data"] == {"a": [1, 2, {"b": 3}]} ================================================ FILE: agent/tests/unit_test/utils/test_reasoning_parsing.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/reasoning_parsing.py.""" from unittest.mock import MagicMock from vss_agents.utils.reasoning_parsing import parse_reasoning_content class TestParseReasoningContent: """Tests for parse_reasoning_content function.""" def test_parse_with_reasoning_content_attribute(self): """Test parsing when response has reasoning_content attribute.""" response = MagicMock() response.reasoning_content = "This is the reasoning" response.content = "This is the actual content" response.additional_kwargs = {} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning == "This is the reasoning" assert content == "This is the actual content" def test_parse_with_reasoning_in_additional_kwargs(self): """Test parsing when reasoning is in additional_kwargs.""" response = MagicMock() response.reasoning_content = None response.content = "Main content" response.additional_kwargs = {"reasoning_content": "Reasoning from kwargs"} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning == "Reasoning from kwargs" assert content == "Main content" def test_parse_with_reasoning_in_response_metadata(self): """Test parsing when reasoning is in response_metadata.""" response = MagicMock() response.reasoning_content = None response.content = "Main content" response.additional_kwargs = {} response.response_metadata = {"reasoning_content": "Reasoning from metadata"} reasoning, content = parse_reasoning_content(response) assert reasoning == "Reasoning from metadata" assert content == "Main content" def test_parse_with_single_think_end_tag(self): """Test parsing with single tag (no opening tag).""" response = MagicMock() response.reasoning_content = None response.content = "I need to analyze thisHere is the answer" response.additional_kwargs = {} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning == "I need to analyze this" assert content == "Here is the answer" def test_parse_with_paired_think_tags(self): """Test parsing with paired tags.""" response = MagicMock() response.reasoning_content = None response.content = "My reasoning processThe final answer" response.additional_kwargs = {} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning == "My reasoning process" assert content == "The final answer" def test_parse_without_reasoning(self): """Test parsing when no reasoning is present.""" response = MagicMock() response.reasoning_content = None response.content = "Just plain content without reasoning" response.additional_kwargs = {} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning is None assert content == "Just plain content without reasoning" def test_parse_with_empty_reasoning(self): """Test parsing with empty reasoning content.""" response = MagicMock() response.reasoning_content = "" response.content = "Content only" response.additional_kwargs = {} response.response_metadata = {} _reasoning, content = parse_reasoning_content(response) # Empty reasoning_content should result in checking content for tags assert content == "Content only" def test_parse_with_whitespace_in_reasoning(self): """Test parsing with whitespace in reasoning.""" response = MagicMock() response.reasoning_content = " reasoning with spaces " response.content = " content with spaces " response.additional_kwargs = {} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning == "reasoning with spaces" assert content == "content with spaces" def test_parse_with_empty_content(self): """Test parsing with empty content.""" response = MagicMock() response.reasoning_content = "Some reasoning" response.content = "" response.additional_kwargs = {} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning == "Some reasoning" assert content is None def test_parse_think_tags_multiline(self): """Test parsing think tags with multiline content.""" response = MagicMock() response.reasoning_content = None response.content = """ Line 1 of reasoning Line 2 of reasoning Line 1 of answer Line 2 of answer""" response.additional_kwargs = {} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert "Line 1 of reasoning" in reasoning assert "Line 2 of reasoning" in reasoning assert "Line 1 of answer" in content assert "Line 2 of answer" in content def test_parse_think_tags_take_priority_over_reasoning_field(self): """Test that think tags in content take priority over reasoning_content field.""" response = MagicMock() response.reasoning_content = None response.content = "Some reasoning here\n\n\nActual content\n" response.additional_kwargs = {"reasoning_content": "Some reasoning here"} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning == "Some reasoning here" assert content == "Actual content" def test_parse_paired_think_tags_take_priority_over_reasoning_field(self): """Test paired tags also take priority over reasoning_content.""" response = MagicMock() response.reasoning_content = None response.content = "My reasoning\nThe final answer" response.additional_kwargs = {"reasoning_content": "My reasoning"} response.response_metadata = {} reasoning, content = parse_reasoning_content(response) assert reasoning == "My reasoning" assert content == "The final answer" def test_parse_think_tags_wrong_order(self): """Test parsing when think tags are in wrong order (should not match).""" response = MagicMock() response.reasoning_content = None response.content = "some content" response.additional_kwargs = {} response.response_metadata = {} # When tags are in wrong order, treat as plain content _reasoning, _content = parse_reasoning_content(response) # The behavior depends on implementation - it should handle this gracefully # --- content_blocks parsing --- def test_parse_content_blocks_reasoning_and_text(self): """Test parsing content_blocks with both reasoning and text blocks.""" response = MagicMock() response.content = "" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [ {"type": "reasoning", "reasoning": "Step-by-step thinking"}, {"type": "text", "text": "The final answer"}, ] reasoning, content = parse_reasoning_content(response) assert reasoning == "Step-by-step thinking" assert content == "The final answer" def test_parse_content_blocks_multiple_blocks(self): """Test parsing content_blocks with multiple reasoning and text blocks.""" response = MagicMock() response.content = "" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [ {"type": "reasoning", "reasoning": "First thought"}, {"type": "reasoning", "reasoning": "Second thought"}, {"type": "text", "text": "Part one"}, {"type": "text", "text": "Part two"}, ] reasoning, content = parse_reasoning_content(response) assert reasoning == "First thought\nSecond thought" assert content == "Part one\nPart two" def test_parse_content_blocks_only_reasoning(self): """Test parsing content_blocks with only reasoning blocks.""" response = MagicMock() response.content = "" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [ {"type": "reasoning", "reasoning": "Only reasoning here"}, ] reasoning, content = parse_reasoning_content(response) assert reasoning == "Only reasoning here" assert content is None def test_parse_content_blocks_only_text(self): """Test parsing content_blocks with only text blocks.""" response = MagicMock() response.content = "" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [ {"type": "text", "text": "Just text, no reasoning"}, ] reasoning, content = parse_reasoning_content(response) assert reasoning is None assert content == "Just text, no reasoning" def test_parse_content_blocks_empty_list(self): """Test parsing when content_blocks is an empty list (falls through to plain content).""" response = MagicMock() response.content = "Fallback content" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [] reasoning, content = parse_reasoning_content(response) assert reasoning is None assert content == "Fallback content" def test_parse_content_blocks_skips_non_dict_items(self): """Test that non-dict items in content_blocks are silently skipped.""" response = MagicMock() response.content = "" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [ "not a dict", 42, {"type": "reasoning", "reasoning": "Valid reasoning"}, None, {"type": "text", "text": "Valid text"}, ] reasoning, content = parse_reasoning_content(response) assert reasoning == "Valid reasoning" assert content == "Valid text" def test_parse_content_blocks_empty_strings(self): """Test content_blocks with empty reasoning/text strings.""" response = MagicMock() response.content = "" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [ {"type": "reasoning", "reasoning": ""}, {"type": "text", "text": ""}, ] reasoning, content = parse_reasoning_content(response) assert reasoning is None assert content is None def test_reasoning_field_takes_priority_over_content_blocks(self): """Test that reasoning_content field is checked before content_blocks.""" response = MagicMock() response.content = "The answer" response.reasoning_content = "Reasoning from field" response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [ {"type": "reasoning", "reasoning": "Reasoning from blocks"}, {"type": "text", "text": "Text from blocks"}, ] reasoning, content = parse_reasoning_content(response) assert reasoning == "Reasoning from field" assert content == "The answer" def test_think_tags_take_priority_over_content_blocks(self): """Test that think tags in content are checked before content_blocks.""" response = MagicMock() response.content = "Think-tag reasoningThink-tag answer" response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = [ {"type": "reasoning", "reasoning": "Block reasoning"}, {"type": "text", "text": "Block text"}, ] reasoning, content = parse_reasoning_content(response) assert reasoning == "Think-tag reasoning" assert content == "Think-tag answer" def test_parse_list_content(self): """Test parsing list-typed content.""" response = MagicMock() response.content = [ {"type": "text", "text": "answer from list"}, {"type": "reasoning", "reasoning": "reasoning from list"}, ] response.reasoning_content = None response.additional_kwargs = {} response.response_metadata = {} response.content_blocks = None reasoning, content = parse_reasoning_content(response) assert reasoning is None assert content is None ================================================ FILE: agent/tests/unit_test/utils/test_reasoning_utils.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/reasoning_utils.py.""" from unittest.mock import MagicMock from vss_agents.utils.reasoning_utils import get_llm_reasoning_bind_kwargs from vss_agents.utils.reasoning_utils import get_thinking_tag class TestGetThinkingTag: """Tests for get_thinking_tag function.""" def test_thinking_none_returns_none(self): """Test that None thinking parameter returns None.""" llm = MagicMock() llm.model_name = "nvidia/nvidia-nemotron" result = get_thinking_tag(llm, None) assert result is None def test_nvidia_nemotron_thinking_enabled(self): """Test NVIDIA Nemotron with thinking enabled.""" llm = MagicMock() llm.model_name = "nvidia/nvidia-nemotron-4" result = get_thinking_tag(llm, True) assert result == "/think" def test_nvidia_nemotron_thinking_disabled(self): """Test NVIDIA Nemotron with thinking disabled.""" llm = MagicMock() llm.model_name = "nvidia/nvidia-nemotron-4" result = get_thinking_tag(llm, False) assert result == "/no_think" def test_nvidia_nemotron_3_nano(self): """Test that Nemotron 3 Nano does not need thinking tag.""" llm = MagicMock() llm.model_name = "nvidia/nvidia-nemotron-3-nano" result = get_thinking_tag(llm, True) assert result is None def test_llama_nemotron_v1_0_thinking_enabled(self): """Test Llama Nemotron v1.0 with thinking enabled.""" llm = MagicMock() llm.model_name = "nvidia/llama-nemotron-v1-0" result = get_thinking_tag(llm, True) assert result == "detailed thinking on" def test_llama_nemotron_v1_0_thinking_disabled(self): """Test Llama Nemotron v1.0 with thinking disabled.""" llm = MagicMock() llm.model_name = "nvidia/llama-nemotron-v1-0" result = get_thinking_tag(llm, False) assert result == "detailed thinking off" def test_llama_nemotron_v1_1_thinking_enabled(self): """Test Llama Nemotron v1.1 with thinking enabled.""" llm = MagicMock() llm.model_name = "nvidia/llama-nemotron-v1-1" result = get_thinking_tag(llm, True) assert result == "detailed thinking on" def test_llama_nemotron_v1_5_thinking_enabled(self): """Test Llama Nemotron v1.5 with thinking enabled.""" llm = MagicMock() llm.model_name = "nvidia/llama-nemotron-v1-5" result = get_thinking_tag(llm, True) assert result == "/think" def test_llama_nemotron_v1_5_thinking_disabled(self): """Test Llama Nemotron v1.5 with thinking disabled.""" llm = MagicMock() llm.model_name = "nvidia/llama-nemotron-v1-5" result = get_thinking_tag(llm, False) assert result == "/no_think" def test_llama_nemotron_newer_version(self): """Test newer Llama Nemotron version uses /think format.""" llm = MagicMock() llm.model_name = "nvidia/llama-nemotron-v2-0" result = get_thinking_tag(llm, True) assert result == "/think" def test_unknown_model(self): """Test unknown model returns None.""" llm = MagicMock() llm.model_name = "unknown/model" result = get_thinking_tag(llm, True) assert result is None def test_model_name_with_underscores(self): """Test model name with underscores (normalized to dashes).""" llm = MagicMock() llm.model_name = "nvidia/nvidia_nemotron_4" result = get_thinking_tag(llm, True) assert result == "/think" def test_model_name_with_dots(self): """Test model name with dots (normalized to dashes).""" llm = MagicMock() llm.model_name = "nvidia/nvidia.nemotron.4" result = get_thinking_tag(llm, True) assert result == "/think" def test_azure_deployment_key(self): """Test using azure_deployment instead of model_name.""" llm = MagicMock() llm.model_name = None llm.model = None llm.azure_deployment = "nvidia/nvidia-nemotron-4" result = get_thinking_tag(llm, True) assert result == "/think" def test_model_key(self): """Test using model key.""" llm = MagicMock() llm.model_name = None llm.model = "nvidia/nvidia-nemotron-4" llm.azure_deployment = None result = get_thinking_tag(llm, True) assert result == "/think" def test_no_model_keys(self): """Test when no model keys are present.""" llm = MagicMock(spec=[]) # No attributes result = get_thinking_tag(llm, True) assert result is None def test_llama_ends_with_v1(self): """Test Llama model ending with just 'v1'.""" llm = MagicMock() llm.model_name = "nvidia/llama-nemotronv1" result = get_thinking_tag(llm, True) assert result == "detailed thinking on" def _make_mock(class_name, model_name="", model=""): """Create a MagicMock whose type().__name__ returns *class_name*.""" mock_cls = type(class_name, (MagicMock,), {}) mock_llm = mock_cls() mock_llm.model_name = model_name mock_llm.model = model return mock_llm class TestGetLlmReasoningBindKwargs: """Test get_llm_reasoning_bind_kwargs function.""" # --- ChatNVIDIA / gpt-oss --- def test_chatnvidia_gpt_oss_reasoning_false(self): mock_llm = _make_mock("ChatNVIDIA", model_name="openai/gpt-oss-20b") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=False) assert result == {"reasoning_effort": "low"} def test_chatnvidia_gpt_oss_reasoning_true(self): mock_llm = _make_mock("ChatNVIDIA", model_name="openai/gpt-oss-20b") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=True) assert result == {"reasoning_effort": "medium"} def test_chatnvidia_gpt_oss_reasoning_none(self): mock_llm = _make_mock("ChatNVIDIA", model_name="openai/gpt-oss-20b") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=None) assert result == {} # --- ChatNVIDIA / nemotron-3 --- def test_chatnvidia_nemotron_reasoning_true(self): mock_llm = _make_mock("ChatNVIDIA", model_name="nvidia/nemotron-3-nano-30b-a3b") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=True) assert result == {"chat_template_kwargs": {"enable_thinking": True}} def test_chatnvidia_nemotron_reasoning_false(self): mock_llm = _make_mock("ChatNVIDIA", model_name="nvidia/nemotron-3-nano-30b-a3b") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=False) assert result == {"chat_template_kwargs": {"enable_thinking": False}} def test_chatnvidia_nemotron_reasoning_none(self): mock_llm = _make_mock("ChatNVIDIA", model_name="nvidia/nemotron-3-nano-30b-a3b") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=None) assert result == {} # --- ChatNVIDIA / other models --- def test_chatnvidia_unknown_model_returns_empty(self): mock_llm = _make_mock("ChatNVIDIA", model_name="nvidia/nvidia-nemotron-nano-9b-v2") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=True) assert result == {} # --- ChatNVIDIA / fallback to model attribute --- def test_chatnvidia_fallback_to_model_attribute(self): mock_llm = _make_mock("ChatNVIDIA", model="openai/gpt-oss-20b") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=True) assert result == {"reasoning_effort": "medium"} # --- ChatOpenAI --- def test_chatopenai_reasoning_true(self): mock_llm = _make_mock("ChatOpenAI", model_name="o3-mini") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=True) assert result == {"reasoning": {"effort": "medium", "summary": "auto"}} def test_chatopenai_reasoning_false(self): mock_llm = _make_mock("ChatOpenAI", model_name="o3-mini") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=False) assert result == {} def test_chatopenai_reasoning_none(self): mock_llm = _make_mock("ChatOpenAI", model_name="o3-mini") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=None) assert result == {} # --- Other / unsupported LLM type --- def test_other_llm_type_returns_empty(self): mock_llm = _make_mock("ChatAnthropic", model_name="claude-3") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=True) assert result == {} def test_other_llm_type_reasoning_false_returns_empty(self): mock_llm = _make_mock("ChatAnthropic", model_name="claude-3") result = get_llm_reasoning_bind_kwargs(mock_llm, llm_reasoning=False) assert result == {} ================================================ FILE: agent/tests/unit_test/utils/test_retry.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/retry.py.""" import pytest from vss_agents.utils.retry import create_retry_strategy class TestCreateRetryStrategy: """Tests for create_retry_strategy function.""" def test_create_retry_strategy_returns_async_retrying(self): """Test that function returns AsyncRetrying instance.""" from tenacity import AsyncRetrying strategy = create_retry_strategy(retries=3) assert isinstance(strategy, AsyncRetrying) def test_create_retry_strategy_default_delay(self): """Test retry strategy with default delay.""" strategy = create_retry_strategy(retries=3) # Verify the strategy was created without error assert strategy is not None def test_create_retry_strategy_custom_delay(self): """Test retry strategy with custom delay.""" strategy = create_retry_strategy(retries=3, delay=5) assert strategy is not None def test_create_retry_strategy_single_retry(self): """Test retry strategy with single retry.""" strategy = create_retry_strategy(retries=1) assert strategy is not None def test_create_retry_strategy_many_retries(self): """Test retry strategy with many retries.""" strategy = create_retry_strategy(retries=10, delay=1) assert strategy is not None @pytest.mark.asyncio async def test_retry_strategy_on_success(self): """Test retry strategy when function succeeds.""" call_count = 0 async def success_func(): nonlocal call_count call_count += 1 return "success" strategy = create_retry_strategy(retries=3) async for attempt in strategy: with attempt: result = await success_func() assert result == "success" assert call_count == 1 @pytest.mark.asyncio async def test_retry_strategy_on_other_exception(self): """Test that non-retryable exceptions are raised immediately.""" strategy = create_retry_strategy(retries=3) async def failing_func(): raise ValueError("Not a connection error") with pytest.raises(ValueError, match="Not a connection error"): async for attempt in strategy: with attempt: await failing_func() ================================================ FILE: agent/tests/unit_test/utils/test_rewrite_url_host.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for rewrite_url_host.""" import pytest from vss_agents.utils.url_translation import rewrite_url_host class TestRewriteUrlHost: """Tests for the rewrite_url_host helper.""" # --- Direct-IP cases (explicit port) --- def test_replaces_host_keeps_port(self): result = rewrite_url_host( "http://232.2.2.34:22324/vst/api/v1/storage/file.mp4", "10.0.1.1", ) assert result == "http://10.0.1.1:22324/vst/api/v1/storage/file.mp4" def test_preserves_scheme(self): result = rewrite_url_host( "https://proxy.example.com:443/vst/api/v1/clip?start=0&end=10#section", "10.0.1.1", ) assert result == "https://10.0.1.1:443/vst/api/v1/clip?start=0&end=10#section" def test_preserves_query_and_fragment(self): result = rewrite_url_host( "http://1.2.3.4:30888/vst/api?key=val#frag", "10.0.0.5", ) assert result == "http://10.0.0.5:30888/vst/api?key=val#frag" def test_no_path(self): result = rewrite_url_host("http://external:9999", "10.0.1.1") assert result == "http://10.0.1.1:9999" def test_root_path(self): result = rewrite_url_host("http://external:9999/", "10.0.1.1") assert result == "http://10.0.1.1:9999/" def test_same_host_with_port(self): result = rewrite_url_host( "http://10.0.1.1:30888/vst/api/v1/storage/file.mp4", "10.0.1.1", ) assert result == "http://10.0.1.1:30888/vst/api/v1/storage/file.mp4" @pytest.mark.parametrize( "url,target_ip,expected", [ ( "http://1.2.3.4:30888/vst/api/v1/clip", "localhost", "http://localhost:30888/vst/api/v1/clip", ), ( "https://brev-proxy.example.com:8443/vst/storage/video.mp4", "10.0.0.5", "https://10.0.0.5:8443/vst/storage/video.mp4", ), ], ) def test_parametrized(self, url, target_ip, expected): assert rewrite_url_host(url, target_ip) == expected # --- Already target IP, no port --- def test_already_target_ip_no_port_returns_unchanged(self): url = "http://10.0.1.1/vst/storage/video.mp4" assert rewrite_url_host(url, "10.0.1.1") == url # --- Proxy cases (no explicit port, host != target_ip) --- def test_proxy_vst_url_rewrites_to_port_30888(self): url = "https://7777-abc123.brevlab.com/vst/storage/temp_files/video.mp4" result = rewrite_url_host(url, "10.0.0.1") assert result == "http://10.0.0.1:30888/vst/storage/temp_files/video.mp4" def test_proxy_static_url_rewrites_to_port_8000(self): url = "https://7777-abc123.brevlab.com/static/vss_report_20260310.pdf" result = rewrite_url_host(url, "10.0.0.1") assert result == "http://10.0.0.1:8000/static/vss_report_20260310.pdf" def test_proxy_api_url_rewrites_to_port_8000(self): url = "https://7777-abc123.brevlab.com/api/v1/videos" result = rewrite_url_host(url, "10.0.0.1") assert result == "http://10.0.0.1:8000/api/v1/videos" def test_proxy_health_url_rewrites_to_port_8000(self): url = "https://7777-abc123.brevlab.com/health" result = rewrite_url_host(url, "10.0.0.1") assert result == "http://10.0.0.1:8000/health" def test_proxy_incidents_url_rewrites_to_port_8081(self): url = "https://7777-abc123.brevlab.com/incidents" result = rewrite_url_host(url, "10.0.0.1") assert result == "http://10.0.0.1:8081/incidents" def test_proxy_unknown_path_uses_default_port(self): """Unknown path prefix falls back to agent port 8000.""" url = "https://7777-abc123.brevlab.com/unknown/path" result = rewrite_url_host(url, "10.0.0.1") assert result == "http://10.0.0.1:8000/unknown/path" def test_proxy_preserves_path_query_fragment(self): url = "https://proxy.example.com/vst/api/v1/replay/stream/123?startTime=2025-01-01#section" result = rewrite_url_host(url, "10.0.0.1") assert result == "http://10.0.0.1:30888/vst/api/v1/replay/stream/123?startTime=2025-01-01#section" ================================================ FILE: agent/tests/unit_test/utils/test_screenshot.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for build_screenshot_url.""" from vss_agents.tools.vst.snapshot import build_screenshot_url class TestBuildScreenshotUrl: """Test build_screenshot_url function.""" def test_build_screenshot_url(self): result = build_screenshot_url("http://vst-external:8080", "stream1", "2025-01-01T00:00:00Z") assert ( result == "http://vst-external:8080/vst/api/v1/replay/stream/stream1/picture?startTime=2025-01-01T00:00:00Z" ) def test_build_screenshot_url_different_params(self): result = build_screenshot_url("https://vst.example.com", "abc-123", "2025-06-15T14:30:00Z") assert ( result == "https://vst.example.com/vst/api/v1/replay/stream/abc-123/picture?startTime=2025-06-15T14:30:00Z" ) def test_build_screenshot_url_always_returns_string(self): """Build function always returns a non-empty string (no validation).""" result = build_screenshot_url("http://host", "id", "ts") assert isinstance(result, str) assert len(result) > 0 def test_build_screenshot_url_strips_trailing_slash(self): """Trailing slash on vst_external_url is stripped to avoid double slashes.""" result = build_screenshot_url("http://vst-external:8080/", "stream1", "2025-01-01T00:00:00Z") assert "//" not in result.split("://", 1)[1] def test_build_screenshot_url_empty_stream_id(self): """Empty stream_id produces a URL with empty segment (caller should guard).""" result = build_screenshot_url("http://host", "", "ts") assert "/stream//picture" in result def test_build_screenshot_url_empty_timestamp(self): """Empty timestamp produces a URL with empty startTime param.""" result = build_screenshot_url("http://host", "s1", "") assert result.endswith("startTime=") ================================================ FILE: agent/tests/unit_test/utils/test_time_measure.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/time_measure.py.""" import time from unittest.mock import patch from vss_agents.utils.time_measure import TimeMeasure class TestTimeMeasure: """Tests for TimeMeasure context manager.""" def test_context_manager_basic(self): """Test basic context manager usage.""" with TimeMeasure("test operation", print=False) as tm: time.sleep(0.01) # 10ms assert tm.execution_time > 0 assert tm.execution_time < 1 # Should be much less than 1 second def test_execution_time_property(self): """Test execution_time property after context exits.""" with TimeMeasure("test", print=False) as tm: time.sleep(0.02) exec_time = tm.execution_time assert exec_time >= 0.01 # At least 10ms def test_current_execution_time_property(self): """Test current_execution_time property during execution.""" with TimeMeasure("test", print=False) as tm: time.sleep(0.01) current_time = tm.current_execution_time assert current_time > 0 assert current_time < 1 def test_timing_accuracy(self): """Test that timing is reasonably accurate.""" sleep_time = 0.05 # 50ms with TimeMeasure("accuracy test", print=False) as tm: time.sleep(sleep_time) # Allow for some tolerance (50-150ms) assert tm.execution_time >= 0.03 assert tm.execution_time < 0.15 def test_print_enabled(self): """Test that print output works when enabled.""" with patch("builtins.print") as mock_print, patch("sys.stderr"), TimeMeasure("print test", print=True): time.sleep(0.001) # Verify print was called mock_print.assert_called() def test_print_disabled(self): """Test that print is skipped when disabled.""" with patch("builtins.print"), TimeMeasure("no print test", print=False): pass # Print should not be called for timing output # (may still be called by logger) def test_string_parameter(self): """Test that string parameter is used in output.""" test_string = "unique operation name" with patch("sys.stderr"), TimeMeasure(test_string, print=True): pass def test_nested_context_managers(self): """Test nested TimeMeasure contexts.""" with TimeMeasure("outer", print=False) as outer: time.sleep(0.01) with TimeMeasure("inner", print=False) as inner: time.sleep(0.01) assert outer.execution_time > inner.execution_time def test_millisecond_format(self): """Test that short operations are formatted in milliseconds.""" with TimeMeasure("ms test", print=False) as tm: time.sleep(0.001) # 1ms # Just verify execution completes without error assert tm.execution_time > 0 def test_second_format(self): """Test that longer operations show in seconds.""" with TimeMeasure("sec test", print=False) as tm: time.sleep(0.001) # 1ms - fast for testing # Verify execution time is captured assert hasattr(tm, "_end_time") assert hasattr(tm, "_start_time") def test_context_manager_enter_return(self): """Test that __enter__ returns self.""" tm = TimeMeasure("test") result = tm.__enter__() assert result is tm tm.__exit__(None, None, None) def test_context_manager_exit_no_exception(self): """Test __exit__ with no exception.""" with TimeMeasure("test", print=False): pass # Should not raise def test_zero_execution_time_handling(self): """Test handling of very fast operations.""" with TimeMeasure("fast", print=False) as tm: pass # Nearly instant # Should handle gracefully, time should be >= 0 assert tm.execution_time >= 0 def test_seconds_format_output(self): """Test output formatting when exec_time > 1 second (covers line 40).""" with patch("sys.stderr"), patch("time.perf_counter") as mock_time: # Simulate 2 seconds execution mock_time.side_effect = [0.0, 2.5] with TimeMeasure("slow test", print=True): pass # Should format as seconds def test_nanoseconds_format_output(self): """Test output formatting when exec_time is nanoseconds (covers line 46).""" with patch("sys.stderr"), patch("time.perf_counter") as mock_time: # Simulate sub-microsecond execution (nanoseconds) mock_time.side_effect = [0.0, 0.0000001] # 100 nanoseconds with TimeMeasure("nano test", print=True): pass # Should format as nanoseconds ================================================ FILE: agent/tests/unit_test/utils/test_url_translation.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for url_translation module.""" from vss_agents.utils.url_translation import translate_url class TestTranslateUrl: """Test translate_url function.""" def test_empty_url_returns_empty(self): result = translate_url("", "remote", "10.0.0.1", "1.2.3.4") assert result == "" def test_none_vlm_mode_returns_original(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, None, "10.0.0.1", "1.2.3.4") assert result == url def test_empty_vlm_mode_returns_original(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "", "10.0.0.1", "1.2.3.4") assert result == url def test_missing_external_ip_returns_original(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "remote", "10.0.0.1", None) assert result == url def test_empty_external_ip_returns_original(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "remote", "10.0.0.1", "") assert result == url def test_missing_internal_ip_returns_original(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "remote", None, "1.2.3.4") assert result == url def test_empty_internal_ip_returns_original(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "remote", "", "1.2.3.4") assert result == url def test_same_ips_returns_original(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "remote", "10.0.0.1", "10.0.0.1") assert result == url def test_remote_mode_internal_to_external(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "remote", "10.0.0.1", "1.2.3.4") assert result == "http://1.2.3.4:8080/video.mp4" def test_remote_mode_no_match(self): url = "http://10.1.2.3:8080/video.mp4" result = translate_url(url, "remote", "10.0.0.1", "1.2.3.4") assert result == url def test_local_mode_external_to_internal(self): url = "http://1.2.3.4:8080/video.mp4" result = translate_url(url, "local", "10.0.0.1", "1.2.3.4") assert result == "http://10.0.0.1:8080/video.mp4" def test_local_shared_mode_external_to_internal(self): url = "http://1.2.3.4:8080/video.mp4" result = translate_url(url, "local_shared", "10.0.0.1", "1.2.3.4") assert result == "http://10.0.0.1:8080/video.mp4" def test_unknown_vlm_mode_returns_original(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "unknown_mode", "10.0.0.1", "1.2.3.4") assert result == url def test_url_without_netloc_returns_original(self): url = "/just/a/path" result = translate_url(url, "remote", "10.0.0.1", "1.2.3.4") assert result == url def test_case_insensitive_vlm_mode(self): url = "http://10.0.0.1:8080/video.mp4" result = translate_url(url, "REMOTE", "10.0.0.1", "1.2.3.4") assert result == "http://1.2.3.4:8080/video.mp4" def test_local_mode_no_match(self): url = "http://10.1.2.3:8080/video.mp4" result = translate_url(url, "local", "10.0.0.1", "1.2.3.4") assert result == url # --- Reverse proxy fallback tests --- # When behind a reverse proxy (e.g., Brev secure links with nginx), # the URL host is the proxy hostname, not a direct IP. def test_proxy_url_local_mode_with_vst_internal_url(self): """Local VLM behind proxy: replace proxy base with internal VST URL.""" url = "https://7777-abc123.brevlab.com/vst/storage/temp_files/video.mp4" result = translate_url(url, "local_shared", "10.0.0.1", "1.2.3.4", "http://10.0.0.1:30888") assert result == "http://10.0.0.1:30888/vst/storage/temp_files/video.mp4" def test_proxy_url_local_mode_without_vst_internal_url(self): """Local VLM behind proxy without vst_internal_url: no translation (backwards compat).""" url = "https://7777-abc123.brevlab.com/vst/storage/temp_files/video.mp4" result = translate_url(url, "local_shared", "10.0.0.1", "1.2.3.4") assert result == url def test_proxy_url_remote_mode_no_fallback(self): """Remote VLM behind proxy: no proxy fallback (only local modes use it).""" url = "https://7777-abc123.brevlab.com/vst/storage/temp_files/video.mp4" result = translate_url(url, "remote", "10.0.0.1", "1.2.3.4", "http://10.0.0.1:30888") assert result == url def test_proxy_url_preserves_path_and_query(self): """Proxy fallback preserves full path and query string.""" url = "https://proxy.example.com/vst/api/v1/replay/stream/123/picture?startTime=2025-01-01" result = translate_url(url, "local", "10.0.0.1", "1.2.3.4", "http://10.0.0.1:30888") assert result == "http://10.0.0.1:30888/vst/api/v1/replay/stream/123/picture?startTime=2025-01-01" def test_proxy_url_vst_internal_url_trailing_slash(self): """Trailing slash on vst_internal_url doesn't cause double-slash.""" url = "https://proxy.example.com/vst/storage/video.mp4" result = translate_url(url, "local", "10.0.0.1", "1.2.3.4", "http://10.0.0.1:30888/") assert result == "http://10.0.0.1:30888/vst/storage/video.mp4" def test_ip_match_takes_priority_over_proxy_fallback(self): """When the IP matches, normal IP swap happens even if vst_internal_url is provided.""" url = "http://1.2.3.4:30888/vst/storage/video.mp4" result = translate_url(url, "local", "10.0.0.1", "1.2.3.4", "http://10.0.0.1:30888") assert result == "http://10.0.0.1:30888/vst/storage/video.mp4" ================================================ FILE: agent/tests/unit_test/utils/test_video_file.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/utils/video_file.py.""" from unittest.mock import MagicMock from unittest.mock import patch from vss_agents.data_models.vss import MediaInfoOffset from vss_agents.utils.video_file import get_video_duration from vss_agents.utils.video_file import pad_media_info class TestGetVideoDuration: """Tests for get_video_duration function.""" def test_get_video_duration_file_not_exists(self, tmp_path): """Test getting duration for non-existent file.""" result = get_video_duration(str(tmp_path / "nonexistent.mp4")) assert result == 0.0 def test_get_video_duration_success(self): """Test getting video duration successfully.""" import cv2 mock_cap = MagicMock() mock_cap.isOpened.return_value = True def mock_get(prop): if prop == cv2.CAP_PROP_FRAME_COUNT: return 1000.0 elif prop == cv2.CAP_PROP_FPS: return 30.0 return 0.0 mock_cap.get.side_effect = mock_get with patch("os.path.exists", return_value=True): with patch("vss_agents.utils.video_file.cv2.VideoCapture", return_value=mock_cap): result = get_video_duration("/fake/path.mp4") # 1000 frames / 30 fps = 33.33 seconds assert abs(result - 33.33) < 0.1 def test_get_video_duration_cannot_open(self): """Test getting duration when video cannot be opened.""" mock_cap = MagicMock() mock_cap.isOpened.return_value = False with patch("os.path.exists", return_value=True): with patch("vss_agents.utils.video_file.cv2.VideoCapture", return_value=mock_cap): result = get_video_duration("/fake/path.mp4") assert result == 0.0 def test_get_video_duration_invalid_fps(self): """Test getting duration with invalid FPS.""" import cv2 mock_cap = MagicMock() mock_cap.isOpened.return_value = True def mock_get(prop): if prop == cv2.CAP_PROP_FRAME_COUNT: return 1000.0 elif prop == cv2.CAP_PROP_FPS: return 0.0 # Invalid FPS return 0.0 mock_cap.get.side_effect = mock_get with patch("os.path.exists", return_value=True): with patch("vss_agents.utils.video_file.cv2.VideoCapture", return_value=mock_cap): result = get_video_duration("/fake/path.mp4") assert result == 0.0 def test_get_video_duration_invalid_frame_count(self): """Test getting duration with invalid frame count.""" import cv2 mock_cap = MagicMock() mock_cap.isOpened.return_value = True def mock_get(prop): if prop == cv2.CAP_PROP_FRAME_COUNT: return -1.0 # Invalid frame count elif prop == cv2.CAP_PROP_FPS: return 30.0 return 0.0 mock_cap.get.side_effect = mock_get with patch("os.path.exists", return_value=True): with patch("vss_agents.utils.video_file.cv2.VideoCapture", return_value=mock_cap): result = get_video_duration("/fake/path.mp4") assert result == 0.0 class TestPadMediaInfo: """Tests for pad_media_info function.""" def test_pad_media_info_basic(self): """Test basic padding of media info.""" media_info = MediaInfoOffset(start_offset=10, end_offset=20) video_duration = 100.0 result = pad_media_info(media_info, video_duration, min_chunk_duration=4) # With min_chunk_duration=4, left_padding=2, right_padding=2 # start: 10 - 2 = 8, end: 20 + 2 = 22 assert result.start_offset == 8 assert result.end_offset == 22 def test_pad_media_info_start_near_zero(self): """Test padding when start is near zero.""" media_info = MediaInfoOffset(start_offset=1, end_offset=20) video_duration = 100.0 result = pad_media_info(media_info, video_duration, min_chunk_duration=4) # Cannot subtract full left_padding (2), so use 1 # left_padding = 1, right_padding = 3 assert result.start_offset == 0 assert result.end_offset >= 20 def test_pad_media_info_end_exceeds_duration(self): """Test padding when end exceeds video duration.""" media_info = MediaInfoOffset(start_offset=90, end_offset=98) video_duration = 100.0 result = pad_media_info(media_info, video_duration, min_chunk_duration=4) # end + right_padding would exceed duration assert result.end_offset == 100 def test_pad_media_info_end_clamped_to_duration(self): """Test padding when end_offset exceeds duration (covers line 57).""" media_info = MediaInfoOffset(start_offset=95, end_offset=99) video_duration = 100.0 result = pad_media_info(media_info, video_duration, min_chunk_duration=10) # With min_chunk_duration=10, left_padding=5, right_padding=5 # end_offset = 99 + 5 = 104 > 100, so clamped to 100 assert result.end_offset == 100 def test_pad_media_info_zero_start(self): """Test padding when start is at zero.""" media_info = MediaInfoOffset(start_offset=0, end_offset=20) video_duration = 100.0 result = pad_media_info(media_info, video_duration, min_chunk_duration=4) # Start stays at 0, right padding gets extra assert result.start_offset == 0 def test_pad_media_info_default_chunk_duration(self): """Test padding with default min_chunk_duration.""" media_info = MediaInfoOffset(start_offset=10, end_offset=20) video_duration = 100.0 result = pad_media_info(media_info, video_duration) # Default min_chunk_duration=2, left_padding=1, right_padding=1 assert result.start_offset == 9 assert result.end_offset == 21 ================================================ FILE: agent/tests/unit_test/video_analytics/__init__.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for vss_agents.video_analytics package.""" ================================================ FILE: agent/tests/unit_test/video_analytics/test_embeddings.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_analytics/embeddings module.""" from unittest.mock import MagicMock from unittest.mock import patch import numpy as np import pytest from vss_agents.video_analytics.embeddings import EmbeddingModel from vss_agents.video_analytics.embeddings import PlaceEmbeddingCache def add_place(cache: PlaceEmbeddingCache, name: str, embedding: np.ndarray) -> None: """Helper to add a single place to the cache.""" embedding_2d = embedding.reshape(1, -1) cache.add_places_batch([name], embedding_2d) class TestEmbeddingModel: """Test EmbeddingModel class.""" @patch("vss_agents.video_analytics.embeddings.SentenceTransformer", create=True) def test_init_success(self, mock_st_class): """Test successful model initialization.""" mock_model = MagicMock() mock_st_class.return_value = mock_model with patch.dict("sys.modules", {"sentence_transformers": MagicMock(SentenceTransformer=mock_st_class)}): with patch("vss_agents.video_analytics.embeddings.EmbeddingModel._load_model") as mock_load: model = EmbeddingModel("test-model") assert model.model_name == "test-model" mock_load.assert_called_once() def test_encode_without_model_raises(self): """Test encode raises when model not loaded.""" with patch("vss_agents.video_analytics.embeddings.EmbeddingModel._load_model"): model = EmbeddingModel() model.model = None with pytest.raises(RuntimeError, match="Embedding model not loaded"): model.encode("test text") def test_encode_batch_without_model_raises(self): """Test encode_batch raises when model not loaded.""" with patch("vss_agents.video_analytics.embeddings.EmbeddingModel._load_model"): model = EmbeddingModel() model.model = None with pytest.raises(RuntimeError, match="Embedding model not loaded"): model.encode_batch(["text1", "text2"]) def test_encode_batch_empty_list(self): """Test encode_batch with empty list returns empty array.""" with patch("vss_agents.video_analytics.embeddings.EmbeddingModel._load_model"): model = EmbeddingModel() model.model = MagicMock() result = model.encode_batch([]) assert result.shape == (0, 0) def test_encode_success(self): """Test successful encoding.""" with patch("vss_agents.video_analytics.embeddings.EmbeddingModel._load_model"): model = EmbeddingModel() mock_model = MagicMock() mock_model.encode.return_value = np.array([0.1, 0.2, 0.3]) model.model = mock_model result = model.encode("test text") np.testing.assert_array_equal(result, np.array([0.1, 0.2, 0.3])) mock_model.encode.assert_called_once_with("test text", convert_to_numpy=True) def test_encode_batch_success(self): """Test successful batch encoding.""" with patch("vss_agents.video_analytics.embeddings.EmbeddingModel._load_model"): model = EmbeddingModel() mock_model = MagicMock() mock_model.encode.return_value = np.array([[0.1, 0.2], [0.3, 0.4]]) model.model = mock_model result = model.encode_batch(["text1", "text2"]) np.testing.assert_array_equal(result, np.array([[0.1, 0.2], [0.3, 0.4]])) def test_encode_exception(self): """Test encode handles exceptions.""" with patch("vss_agents.video_analytics.embeddings.EmbeddingModel._load_model"): model = EmbeddingModel() mock_model = MagicMock() mock_model.encode.side_effect = Exception("Encode error") model.model = mock_model with pytest.raises(Exception, match="Encode error"): model.encode("test") def test_encode_batch_exception(self): """Test encode_batch handles exceptions.""" with patch("vss_agents.video_analytics.embeddings.EmbeddingModel._load_model"): model = EmbeddingModel() mock_model = MagicMock() mock_model.encode.side_effect = Exception("Batch error") model.model = mock_model with pytest.raises(Exception, match="Batch error"): model.encode_batch(["text1"]) def test_load_model_success(self): """Test _load_model successfully loads model.""" mock_st = MagicMock() mock_model_instance = MagicMock() mock_st.return_value = mock_model_instance with patch.dict( "sys.modules", {"sentence_transformers": MagicMock(SentenceTransformer=mock_st)}, ): # Reimport to get fresh class import importlib import vss_agents.video_analytics.embeddings as emb_module importlib.reload(emb_module) model = emb_module.EmbeddingModel("test-model") assert model.model is not None def test_load_model_failure(self): """Test _load_model handles import failure.""" with ( patch.dict("sys.modules", {"sentence_transformers": None}), patch( "vss_agents.video_analytics.embeddings.EmbeddingModel._load_model", side_effect=ImportError("Module not found"), ), pytest.raises(ImportError), ): EmbeddingModel() class TestPlaceEmbeddingCache: """Test PlaceEmbeddingCache class.""" def test_init(self): """Test cache initialization.""" cache = PlaceEmbeddingCache() assert cache.place_names == [] assert cache.embeddings is None def test_add_place(self): """Test adding a place to the cache.""" cache = PlaceEmbeddingCache() embedding = np.array([0.1, 0.2, 0.3]) add_place(cache, "Main Street", embedding) assert len(cache.place_names) == 1 assert cache.place_names[0] == "Main Street" assert cache.embeddings is not None assert cache.embeddings.shape == (1, 3) def test_add_multiple_places(self): """Test adding multiple places to the cache.""" cache = PlaceEmbeddingCache() add_place(cache, "Place 1", np.array([0.1, 0.2, 0.3])) add_place(cache, "Place 2", np.array([0.4, 0.5, 0.6])) add_place(cache, "Place 3", np.array([0.7, 0.8, 0.9])) assert len(cache.place_names) == 3 assert cache.embeddings.shape == (3, 3) def test_find_similar_empty_cache(self): """Test finding similar places in an empty cache.""" cache = PlaceEmbeddingCache() query_embedding = np.array([0.1, 0.2, 0.3]) results = cache.find_similar(query_embedding) assert results == [] def test_find_similar_with_results(self): """Test finding similar places with results.""" cache = PlaceEmbeddingCache() # Add some places with normalized embeddings add_place(cache, "Main Street", np.array([1.0, 0.0, 0.0])) add_place(cache, "Oak Avenue", np.array([0.9, 0.1, 0.0])) add_place(cache, "River Road", np.array([0.0, 1.0, 0.0])) # Query with embedding similar to first two query = np.array([0.95, 0.05, 0.0]) results = cache.find_similar(query, top_k=2) assert len(results) <= 2 # Results should be sorted by similarity def test_find_similar_with_threshold(self): """Test finding similar places with threshold.""" cache = PlaceEmbeddingCache() add_place(cache, "Match", np.array([1.0, 0.0, 0.0])) add_place(cache, "No Match", np.array([0.0, 0.0, 1.0])) query = np.array([1.0, 0.0, 0.0]) results = cache.find_similar(query, threshold=0.9) # Only the exact match should be returned assert len(results) >= 1 def test_cache_length(self): """Test getting cache length via place_names.""" cache = PlaceEmbeddingCache() assert len(cache.place_names) == 0 add_place(cache, "Place 1", np.array([0.1, 0.2, 0.3])) assert len(cache.place_names) == 1 add_place(cache, "Place 2", np.array([0.4, 0.5, 0.6])) assert len(cache.place_names) == 2 def test_find_similar_top_k(self): """Test top_k parameter in find_similar.""" cache = PlaceEmbeddingCache() for i in range(10): add_place(cache, f"Place {i}", np.array([float(i), 0.0, 0.0])) query = np.array([5.0, 0.0, 0.0]) results = cache.find_similar(query, top_k=3) assert len(results) <= 3 def test_2d_embedding(self): """Test handling of 2D embedding array.""" cache = PlaceEmbeddingCache() embedding_2d = np.array([[0.1, 0.2, 0.3]]) # Should handle 2D array add_place(cache, "Test", embedding_2d.flatten()) assert len(cache.place_names) == 1 def test_size_method(self): """Test size() method returns correct count.""" cache = PlaceEmbeddingCache() assert cache.size() == 0 add_place(cache, "Place 1", np.array([0.1, 0.2, 0.3])) assert cache.size() == 1 add_place(cache, "Place 2", np.array([0.4, 0.5, 0.6])) assert cache.size() == 2 def test_add_places_batch_empty(self): """Test add_places_batch with empty list does nothing.""" cache = PlaceEmbeddingCache() cache.add_places_batch([], np.array([]).reshape(0, 3)) assert cache.size() == 0 assert cache.embeddings is None def test_add_places_batch_mismatch_raises(self): """Test add_places_batch raises on mismatch.""" cache = PlaceEmbeddingCache() with pytest.raises(ValueError, match="Mismatch"): cache.add_places_batch(["Place 1", "Place 2"], np.array([[0.1, 0.2, 0.3]])) def test_add_places_batch_multiple(self): """Test add_places_batch with multiple places at once.""" cache = PlaceEmbeddingCache() names = ["Place 1", "Place 2", "Place 3"] embeddings = np.array( [ [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9], ] ) cache.add_places_batch(names, embeddings) assert cache.size() == 3 assert cache.embeddings.shape == (3, 3) ================================================ FILE: agent/tests/unit_test/video_analytics/test_es_client.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/video_analytics/es_client.py.""" from copy import deepcopy import pytest from vss_agents.video_analytics.es_client import BASE_QUERY_TEMPLATE from vss_agents.video_analytics.es_client import ESClient class TestBaseQueryTemplate: """Tests for BASE_QUERY_TEMPLATE constant.""" def test_template_structure(self): """Test that BASE_QUERY_TEMPLATE has correct structure.""" assert "query" in BASE_QUERY_TEMPLATE assert "bool" in BASE_QUERY_TEMPLATE["query"] assert "must" in BASE_QUERY_TEMPLATE["query"]["bool"] def test_template_is_dict(self): """Test that template is a dictionary.""" assert isinstance(BASE_QUERY_TEMPLATE, dict) def test_template_deepcopy_independence(self): """Test that deepcopy creates independent copy.""" copy1 = deepcopy(BASE_QUERY_TEMPLATE) copy2 = deepcopy(BASE_QUERY_TEMPLATE) copy1["query"]["bool"]["must"].append({"test": "value"}) # Original should be unchanged assert len(BASE_QUERY_TEMPLATE["query"]["bool"]["must"]) == 0 # Copy2 should be unchanged assert len(copy2["query"]["bool"]["must"]) == 0 def test_template_must_is_list(self): """Test that must clause is a list.""" assert isinstance(BASE_QUERY_TEMPLATE["query"]["bool"]["must"], list) class TestESClient: """Tests for ESClient class.""" def test_client_initialization(self): """Test ESClient initialization.""" client = ESClient("http://localhost:9200") assert client.index_prefix == "" assert client.client is not None def test_client_with_prefix(self): """Test ESClient with index prefix.""" client = ESClient("http://localhost:9200", index_prefix="test-") assert client.index_prefix == "test-" def test_get_index_valid_key(self): """Test get_index with valid key.""" client = ESClient("http://localhost:9200") index = client.get_index("incidents") assert index == "incidents-*" def test_get_index_with_prefix(self): """Test get_index with prefix.""" client = ESClient("http://localhost:9200", index_prefix="prod-") index = client.get_index("incidents") assert index == "prod-incidents-*" def test_get_index_invalid_key(self): """Test get_index with invalid key raises ValueError.""" client = ESClient("http://localhost:9200") with pytest.raises(ValueError, match="Invalid index key"): client.get_index("invalid_index") def test_indexes_whitelist(self): """Test INDEXES class variable contains expected keys.""" expected_keys = ["incidents", "vlm_incidents", "behavior", "frames", "calibration"] for key in expected_keys: assert key in ESClient.INDEXES def test_all_indexes_are_strings(self): """Test all index values are strings.""" for key, value in ESClient.INDEXES.items(): assert isinstance(key, str) assert isinstance(value, str) def test_incidents_index_pattern(self): """Test incidents index has wildcard pattern.""" assert "*" in ESClient.INDEXES["incidents"] def test_calibration_index_no_wildcard(self): """Test calibration index has no wildcard.""" assert "*" not in ESClient.INDEXES["calibration"] ================================================ FILE: agent/tests/unit_test/video_analytics/test_interface.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for interface module.""" import pytest from vss_agents.video_analytics.interface import IncidentMetadata from vss_agents.video_analytics.interface import VideoAnalyticsInterface class TestIncidentMetadata: """Test IncidentMetadata enum.""" def test_place_value(self): assert IncidentMetadata.PLACE == "place" def test_category_value(self): assert IncidentMetadata.CATEGORY == "category" def test_is_anomaly_value(self): assert IncidentMetadata.IS_ANOMALY == "isAnomaly" def test_object_ids_value(self): assert IncidentMetadata.OBJECT_IDS == "objectIds" def test_frame_ids_value(self): assert IncidentMetadata.FRAME_IDS == "frameIds" def test_analytics_module_value(self): assert IncidentMetadata.ANALYTICS_MODULE == "analyticsModule" def test_type_value(self): assert IncidentMetadata.TYPE == "type" def test_info_value(self): assert IncidentMetadata.INFO == "info" def test_all_values_count(self): assert len(IncidentMetadata) == 8 class TestVideoAnalyticsInterface: """Test VideoAnalyticsInterface abstract class.""" def test_interface_is_abstract(self): """Test that interface cannot be instantiated directly.""" with pytest.raises(TypeError): VideoAnalyticsInterface() def test_interface_defines_methods(self): """Test that interface defines required abstract methods.""" assert hasattr(VideoAnalyticsInterface, "get_incident") assert hasattr(VideoAnalyticsInterface, "get_incidents") assert hasattr(VideoAnalyticsInterface, "get_sensor_ids") assert hasattr(VideoAnalyticsInterface, "get_places") assert hasattr(VideoAnalyticsInterface, "get_fov_histogram") assert hasattr(VideoAnalyticsInterface, "get_average_speeds") assert hasattr(VideoAnalyticsInterface, "analyze") def test_concrete_implementation(self): """Test that a concrete implementation can be created.""" class ConcreteImplementation(VideoAnalyticsInterface): async def get_incident(self, id, *, includes=None): return None async def get_incidents( self, start_time=None, end_time=None, *, source=None, source_type=None, max_count=10, includes=None, vlm_verdict=None, ): return ([], False) async def get_sensor_ids(self, place=None): return [] async def get_places(self): return {} async def get_fov_histogram(self, source, start_time, end_time, object_type=None, bucket_count=10): return {} async def get_average_speeds(self, source, start_time, end_time, source_type): return {} async def analyze(self, start_time, end_time, source, source_type, analysis_type): return "" # Should not raise impl = ConcreteImplementation() assert impl is not None ================================================ FILE: agent/tests/unit_test/video_analytics/test_nvschema.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for nvschema module.""" from vss_agents.video_analytics.nvschema import Coordinates from vss_agents.video_analytics.nvschema import Incident from vss_agents.video_analytics.nvschema import Location from vss_agents.video_analytics.nvschema import Place class TestLocation: """Test Location model.""" def test_location_defaults(self): loc = Location() assert loc.latitude == 0 assert loc.longitude == 0 assert loc.altitude == 0 def test_location_with_values(self): # Must use aliases (lat, lon, alt) as model uses alias loc = Location(lat=37.7749, lon=-122.4194, alt=100.0) assert loc.latitude == 37.7749 assert loc.longitude == -122.4194 assert loc.altitude == 100.0 def test_location_with_aliases(self): loc = Location(lat=40.7128, lon=-74.0060, alt=50.0) assert loc.latitude == 40.7128 assert loc.longitude == -74.0060 assert loc.altitude == 50.0 class TestCoordinates: """Test Coordinates model.""" def test_coordinates_defaults(self): coords = Coordinates() assert coords.latitude == 0 assert coords.longitude == 0 assert coords.altitude == 0 def test_coordinates_with_values(self): # Must use aliases (lat, lon, alt) as model uses alias coords = Coordinates(lat=51.5074, lon=-0.1278, alt=11.0) assert coords.latitude == 51.5074 assert coords.longitude == -0.1278 def test_coordinates_with_aliases(self): coords = Coordinates(lat=35.6762, lon=139.6503, alt=40.0) assert coords.latitude == 35.6762 assert coords.longitude == 139.6503 class TestPlace: """Test Place model.""" def test_place_minimal(self): # Must use alias 'type' instead of 'place_type' place = Place(id="place-001", name="Main Street", type="intersection") assert place.id == "place-001" assert place.name == "Main Street" assert place.place_type == "intersection" assert place.location is None assert place.coordinates is None def test_place_with_location(self): loc = Location(lat=37.7749, lon=-122.4194) place = Place( id="place-002", name="Downtown", type="area", location=loc, ) assert place.location is not None assert place.location.latitude == 37.7749 def test_place_with_coordinates(self): coords = Coordinates(lat=40.7128, lon=-74.0060) place = Place( id="place-003", name="Times Square", type="landmark", coordinates=coords, ) assert place.coordinates is not None assert place.coordinates.latitude == 40.7128 def test_place_with_aliases(self): place = Place( id="p1", name="Test Place", type="test", ) assert place.place_type == "test" class TestIncident: """Test Incident model.""" def test_incident_minimal(self): incident = Incident( id="incident-001", sensor_id="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T00:01:00.000Z", ) assert incident.id == "incident-001" assert incident.sensor_id == "sensor-001" assert incident.start_time == "2025-01-01T00:00:00.000Z" assert incident.end_time == "2025-01-01T00:01:00.000Z" def test_incident_with_aliases(self): incident = Incident( Id="i1", sensorId="s1", timestamp="2025-01-01T00:00:00.000Z", end="2025-01-01T01:00:00.000Z", ) assert incident.id == "i1" assert incident.sensor_id == "s1" assert incident.start_time == "2025-01-01T00:00:00.000Z" assert incident.end_time == "2025-01-01T01:00:00.000Z" def test_incident_with_optional_fields(self): place = Place(id="p1", name="Test", place_type="intersection") incident = Incident( id="i2", sensor_id="s2", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", place=place, category="traffic", object_ids=["obj1", "obj2"], frame_ids=["frame1"], analytics_module="va-module", info={"key": "value"}, incident_type="collision", is_anomaly=True, ) assert incident.place is not None assert incident.place.name == "Test" assert incident.category == "traffic" assert incident.object_ids == ["obj1", "obj2"] assert incident.frame_ids == ["frame1"] assert incident.analytics_module == "va-module" assert incident.info == {"key": "value"} assert incident.incident_type == "collision" assert incident.is_anomaly is True def test_incident_with_aliased_optional_fields(self): incident = Incident( Id="i3", sensorId="s3", timestamp="2025-01-01T00:00:00.000Z", end="2025-01-01T01:00:00.000Z", objectIds=["o1"], frameIds=["f1"], analyticsModule="mod", isAnomaly=False, ) assert incident.object_ids == ["o1"] assert incident.frame_ids == ["f1"] assert incident.analytics_module == "mod" assert incident.is_anomaly is False def test_incident_allows_extra_fields(self): incident = Incident( id="i4", sensor_id="s4", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", extra_field="extra_value", ) assert incident.id == "i4" # Extra fields are allowed due to model_config def test_incident_serialization(self): incident = Incident( id="i5", sensor_id="s5", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", category="test", ) data = incident.model_dump() assert "id" in data assert data["category"] == "test" ================================================ FILE: agent/tests/unit_test/video_analytics/test_query_builders.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/video_analytics/query_builders.py.""" from vss_agents.video_analytics.query_builders import BehaviorQueryBuilder from vss_agents.video_analytics.query_builders import FramesQueryBuilder from vss_agents.video_analytics.query_builders import IncidentQueryBuilder class TestIncidentQueryBuilder: """Tests for IncidentQueryBuilder class.""" def test_build_query_by_id(self): """Test building query by incident ID.""" query = IncidentQueryBuilder.build_query_by_id("incident-123") assert "query" in query assert "bool" in query["query"] # Should have a term match for Id.keyword must_clauses = query["query"]["bool"]["must"] assert any("term" in clause and "Id.keyword" in clause.get("term", {}) for clause in must_clauses) def test_build_query_basic(self): """Test building basic incident query.""" query = IncidentQueryBuilder.build_query( source=None, source_type=None, start_time=None, end_time=None, ) assert "query" in query assert "bool" in query["query"] def test_build_query_with_sensor(self): """Test building query with sensor filter.""" query = IncidentQueryBuilder.build_query( source="sensor-001", source_type="sensor", start_time="2022-08-25T00:00:00.000Z", end_time="2022-08-25T01:00:00.000Z", ) must_clauses = query["query"]["bool"]["must"] # Should have sensorId.keyword term assert any("term" in clause for clause in must_clauses) def test_build_query_with_place(self): """Test building query with place filter.""" query = IncidentQueryBuilder.build_query( source="San Jose", source_type="place", start_time="2022-08-25T00:00:00.000Z", end_time="2022-08-25T01:00:00.000Z", ) must_clauses = query["query"]["bool"]["must"] # Should have wildcard match for place assert any("wildcard" in clause for clause in must_clauses) def test_build_query_with_time_range(self): """Test building query with time range.""" query = IncidentQueryBuilder.build_query( source=None, source_type=None, start_time="2022-08-25T00:00:00.000Z", end_time="2022-08-25T01:00:00.000Z", ) must_clauses = query["query"]["bool"]["must"] # Should have range filters assert any("range" in clause for clause in must_clauses) def test_build_query_vlm_verified(self): """Test building query with VLM verification.""" query = IncidentQueryBuilder.build_query( source=None, source_type=None, start_time=None, end_time=None, vlm_verified=True, vlm_verdict="confirmed", ) must_clauses = query["query"]["bool"]["must"] # Should have verdict filter assert any("term" in clause for clause in must_clauses) def test_build_query_vlm_not_confirmed(self): """Test building query with not-confirmed VLM verdict.""" query = IncidentQueryBuilder.build_query( source=None, source_type=None, start_time=None, end_time=None, vlm_verified=True, vlm_verdict="not-confirmed", ) must_clauses = query["query"]["bool"]["must"] # Should have terms filter for rejected and verification-failed assert any("terms" in clause for clause in must_clauses) def test_build_query_vlm_verdict_all(self): """Test building query with 'all' VLM verdict (covers line 92).""" query = IncidentQueryBuilder.build_query( source=None, source_type=None, start_time=None, end_time=None, vlm_verified=True, vlm_verdict="all", ) # With "all" verdict, no additional verdict filter should be added # Query should still be valid assert "query" in query assert "bool" in query["query"] class TestFramesQueryBuilder: """Tests for FramesQueryBuilder class.""" def test_build_query(self): """Test building frames query.""" query = FramesQueryBuilder.build_query( sensor_id="sensor-001", start_time="2022-08-25T00:00:00.000Z", end_time="2022-08-25T01:00:00.000Z", ) assert "query" in query must_clauses = query["query"]["bool"]["must"] # Should have sensor filter assert any("term" in clause for clause in must_clauses) # Should have time range assert any("range" in clause for clause in must_clauses) def test_fov_histogram_aggregation(self): """Test FOV histogram aggregation.""" agg = FramesQueryBuilder.fov_histogram_aggregation(bucket_size_sec=60) assert "eventsOverTime" in agg assert "date_histogram" in agg["eventsOverTime"] assert agg["eventsOverTime"]["date_histogram"]["fixed_interval"] == "60s" def test_fov_histogram_with_object_type(self): """Test FOV histogram aggregation with object type filter.""" agg = FramesQueryBuilder.fov_histogram_aggregation(bucket_size_sec=60, object_type="person") assert "eventsOverTime" in agg # Should have filter for object type filter_bool = agg["eventsOverTime"]["aggs"]["fov"]["aggs"]["searchAggFilter"]["filter"]["bool"]["filter"] assert len(filter_bool) > 0 class TestBehaviorQueryBuilder: """Tests for BehaviorQueryBuilder class.""" def test_default_constants(self): """Test default constants.""" assert BehaviorQueryBuilder.DEFAULT_STATIONARY_OBJECT_MAX_TIME_INTERVAL_SEC == 500 assert BehaviorQueryBuilder.DEFAULT_STATIONARY_OBJECT_MIN_DISTANCE_METERS == 5 assert BehaviorQueryBuilder.DEFAULT_SHORT_LIVED_BEHAVIOR_MIN_TIME_INTERVAL_SEC == 3 def test_build_average_speed_query_sensor(self): """Test building average speed query for sensor.""" query = BehaviorQueryBuilder.build_average_speed_query( source="sensor-001", source_type="sensor", start_time="2022-08-25T00:00:00.000Z", end_time="2022-08-25T01:00:00.000Z", ) assert "query" in query must_clauses = query["query"]["bool"]["must"] # Should have time range filters assert any("range" in clause for clause in must_clauses) # Should have sensor filter assert any("term" in clause for clause in must_clauses) def test_build_average_speed_query_place(self): """Test building average speed query for place.""" query = BehaviorQueryBuilder.build_average_speed_query( source="San Jose", source_type="place", start_time="2022-08-25T00:00:00.000Z", end_time="2022-08-25T01:00:00.000Z", ) must_clauses = query["query"]["bool"]["must"] # Should have wildcard for place assert any("wildcard" in clause for clause in must_clauses) def test_average_speed_per_direction_aggregation(self): """Test average speed per direction aggregation.""" agg = BehaviorQueryBuilder.average_speed_per_direction_aggregation() assert "directions" in agg assert "terms" in agg["directions"] assert "averageSpeed" in agg["directions"]["aggs"] ================================================ FILE: agent/tests/unit_test/video_analytics/test_tools.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for video_analytics/tools module.""" from pydantic import ValidationError import pytest from vss_agents.video_analytics.tools import AnalyzeInput from vss_agents.video_analytics.tools import AverageSpeedsInput from vss_agents.video_analytics.tools import EmptyInput from vss_agents.video_analytics.tools import FovHistogramInput from vss_agents.video_analytics.tools import GetIncidentInput from vss_agents.video_analytics.tools import GetIncidentsInputBase from vss_agents.video_analytics.tools import GetIncidentsInputWithVLM from vss_agents.video_analytics.tools import GetSensorIdsInput from vss_agents.video_analytics.tools import VideoAnalyticsToolConfig class TestEmptyInput: """Test EmptyInput model.""" def test_empty_input_creation(self): input_data = EmptyInput() assert input_data is not None class TestGetSensorIdsInput: """Test GetSensorIdsInput model.""" def test_no_place_filter(self): input_data = GetSensorIdsInput() assert input_data.place is None def test_with_place_filter(self): input_data = GetSensorIdsInput(place="Main Street") assert input_data.place == "Main Street" class TestGetIncidentInput: """Test GetIncidentInput model.""" def test_basic_input(self): input_data = GetIncidentInput(id="incident-001") assert input_data.id == "incident-001" assert input_data.includes is None def test_with_includes(self): input_data = GetIncidentInput(id="incident-002", includes=["place", "category", "type"]) assert input_data.includes == ["place", "category", "type"] class TestGetIncidentsInputBase: """Test GetIncidentsInputBase model.""" def test_defaults(self): input_data = GetIncidentsInputBase() assert input_data.source is None assert input_data.source_type is None assert input_data.start_time is None assert input_data.end_time is None assert input_data.max_count == 10 assert input_data.includes is None def test_with_source_and_type_sensor(self): input_data = GetIncidentsInputBase(source="sensor-001", source_type="sensor") assert input_data.source == "sensor-001" assert input_data.source_type == "sensor" def test_with_source_and_type_place(self): input_data = GetIncidentsInputBase(source="Main Street", source_type="place") assert input_data.source_type == "place" def test_invalid_source_type(self): with pytest.raises(ValidationError): GetIncidentsInputBase(source="test", source_type="invalid") def test_source_without_type_fails(self): with pytest.raises(ValidationError): GetIncidentsInputBase(source="sensor-001") def test_type_without_source_fails(self): with pytest.raises(ValidationError): GetIncidentsInputBase(source_type="sensor") def test_with_time_range(self): input_data = GetIncidentsInputBase(start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z") assert input_data.start_time == "2025-01-01T00:00:00.000Z" assert input_data.end_time == "2025-01-01T23:59:59.000Z" def test_start_time_without_end_time_fails(self): with pytest.raises(ValidationError): GetIncidentsInputBase(start_time="2025-01-01T00:00:00.000Z") def test_end_time_without_start_time_fails(self): with pytest.raises(ValidationError): GetIncidentsInputBase(end_time="2025-01-01T23:59:59.000Z") def test_with_includes(self): input_data = GetIncidentsInputBase(includes=["place", "category"]) assert input_data.includes == ["place", "category"] def test_custom_max_count(self): input_data = GetIncidentsInputBase(max_count=50) assert input_data.max_count == 50 class TestGetIncidentsInputWithVLM: """Test GetIncidentsInputWithVLM model.""" def test_vlm_verdict_all(self): input_data = GetIncidentsInputWithVLM(vlm_verdict="all") assert input_data.vlm_verdict == "all" def test_vlm_verdict_confirmed(self): input_data = GetIncidentsInputWithVLM(vlm_verdict="confirmed") assert input_data.vlm_verdict == "confirmed" def test_vlm_verdict_rejected(self): input_data = GetIncidentsInputWithVLM(vlm_verdict="rejected") assert input_data.vlm_verdict == "rejected" def test_vlm_verdict_verification_failed(self): input_data = GetIncidentsInputWithVLM(vlm_verdict="verification-failed") assert input_data.vlm_verdict == "verification-failed" def test_vlm_verdict_not_confirmed(self): input_data = GetIncidentsInputWithVLM(vlm_verdict="not-confirmed") assert input_data.vlm_verdict == "not-confirmed" def test_vlm_verdict_invalid(self): with pytest.raises(ValidationError): GetIncidentsInputWithVLM(vlm_verdict="invalid") def test_vlm_verdict_none(self): input_data = GetIncidentsInputWithVLM() assert input_data.vlm_verdict is None class TestFovHistogramInput: """Test FovHistogramInput model.""" def test_basic_input(self): input_data = FovHistogramInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z" ) assert input_data.source == "sensor-001" assert input_data.object_type is None assert input_data.bucket_count == 10 def test_with_object_type(self): input_data = FovHistogramInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", object_type="Person", ) assert input_data.object_type == "Person" def test_custom_bucket_count(self): input_data = FovHistogramInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", bucket_count=20, ) assert input_data.bucket_count == 20 class TestAverageSpeedsInput: """Test AverageSpeedsInput model.""" def test_sensor_source(self): input_data = AverageSpeedsInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source_type="sensor", ) assert input_data.source_type == "sensor" def test_place_source(self): input_data = AverageSpeedsInput( source="Main Street", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source_type="place", ) assert input_data.source_type == "place" def test_invalid_source_type(self): with pytest.raises(ValidationError): AverageSpeedsInput( source="test", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source_type="invalid", ) class TestAnalyzeInput: """Test AnalyzeInput model.""" def test_max_min_incidents(self): input_data = AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="max_min_incidents", ) assert input_data.analysis_type == "max_min_incidents" def test_average_speed(self): input_data = AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="average_speed", ) assert input_data.analysis_type == "average_speed" def test_avg_num_people(self): input_data = AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="avg_num_people", ) assert input_data.analysis_type == "avg_num_people" def test_avg_num_vehicles(self): input_data = AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="avg_num_vehicles", ) assert input_data.analysis_type == "avg_num_vehicles" def test_invalid_analysis_type(self): with pytest.raises(ValidationError): AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="invalid", ) def test_invalid_source_type(self): with pytest.raises(ValidationError): AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="test", source_type="invalid", analysis_type="max_min_incidents", ) class TestVideoAnalyticsToolConfig: """Test VideoAnalyticsToolConfig model.""" def test_defaults(self): config = VideoAnalyticsToolConfig() assert config.es_url == "http://localhost:9200" assert config.index_prefix == "" assert config.vlm_verified is False assert config.vst_sensor_list_tool is None assert config.embedding_model_name == "sentence-transformers/all-MiniLM-L6-v2" assert "get_incidents" in config.include assert "get_incident" in config.include def test_custom_es_url(self): config = VideoAnalyticsToolConfig(es_url="http://custom:9200") assert config.es_url == "http://custom:9200" def test_with_index_prefix(self): config = VideoAnalyticsToolConfig(index_prefix="test-") assert config.index_prefix == "test-" def test_vlm_verified_enabled(self): config = VideoAnalyticsToolConfig(vlm_verified=True) assert config.vlm_verified is True def test_custom_include_list(self): config = VideoAnalyticsToolConfig(include=["get_incidents"]) assert config.include == ["get_incidents"] def test_no_embedding_model(self): config = VideoAnalyticsToolConfig(embedding_model_name=None) assert config.embedding_model_name is None def test_with_vst_sensor_list_tool(self): config = VideoAnalyticsToolConfig(vst_sensor_list_tool="vst_sensor_list") assert config.vst_sensor_list_tool == "vst_sensor_list" class TestTimestampValidation: """Test timestamp validation in input models.""" def test_valid_time_formats(self): """Test various valid timestamp formats.""" valid_timestamps = [ "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", "2022-06-15T12:30:45.123Z", ] for ts in valid_timestamps: input_data = GetIncidentsInputBase(start_time=ts, end_time=ts) assert input_data.start_time == ts def test_invalid_timestamp_no_z(self): """Test invalid timestamp without Z suffix.""" with pytest.raises(ValidationError): GetIncidentsInputBase(start_time="2025-01-01T00:00:00.000", end_time="2025-01-01T00:00:00.000") def test_invalid_timestamp_no_milliseconds(self): """Test invalid timestamp without milliseconds.""" with pytest.raises(ValidationError): GetIncidentsInputBase(start_time="2025-01-01T00:00:00Z", end_time="2025-01-01T00:00:00Z") def test_fov_histogram_timestamp_validation(self): """Test FovHistogramInput timestamp validation.""" with pytest.raises(ValidationError): FovHistogramInput(source="sensor-001", start_time="invalid", end_time="2025-01-01T00:00:00.000Z") def test_average_speeds_timestamp_validation(self): """Test AverageSpeedsInput timestamp validation.""" with pytest.raises(ValidationError): AverageSpeedsInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="invalid", source_type="sensor" ) class TestAnalyzeInputValidation: """Additional tests for AnalyzeInput model.""" def test_all_analysis_types(self): """Test all valid analysis types.""" valid_types = ["max_min_incidents", "average_speed", "avg_num_people", "avg_num_vehicles"] for analysis_type in valid_types: input_data = AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type=analysis_type, ) assert input_data.analysis_type == analysis_type def test_place_source_type(self): """Test place source type for AnalyzeInput.""" input_data = AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="San Jose", source_type="place", analysis_type="max_min_incidents", ) assert input_data.source_type == "place" class TestGetIncidentsInputValidation: """Additional tests for GetIncidentsInput models.""" def test_full_input_with_all_fields(self): """Test input with all optional fields populated.""" input_data = GetIncidentsInputBase( source="sensor-001", source_type="sensor", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", max_count=50, includes=["place", "category", "type", "sensorId"], ) assert input_data.max_count == 50 assert len(input_data.includes) == 4 def test_vlm_input_with_time_and_source(self): """Test VLM input with all filters.""" input_data = GetIncidentsInputWithVLM( source="Main Street", source_type="place", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", vlm_verdict="confirmed", max_count=100, ) assert input_data.vlm_verdict == "confirmed" assert input_data.source_type == "place" class TestInputModelSerialization: """Test serialization/deserialization of input models.""" def test_empty_input_serialization(self): """Test EmptyInput model dump.""" input_data = EmptyInput() data = input_data.model_dump() assert data == {} def test_get_sensor_ids_input_serialization(self): """Test GetSensorIdsInput serialization.""" input_data = GetSensorIdsInput(place="Test Place") data = input_data.model_dump() assert data["place"] == "Test Place" def test_get_incident_input_serialization(self): """Test GetIncidentInput serialization.""" input_data = GetIncidentInput(id="incident-123", includes=["place"]) data = input_data.model_dump() assert data["id"] == "incident-123" assert data["includes"] == ["place"] def test_fov_histogram_input_serialization(self): """Test FovHistogramInput serialization.""" input_data = FovHistogramInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", object_type="Person", bucket_count=20, ) data = input_data.model_dump() assert data["object_type"] == "Person" assert data["bucket_count"] == 20 def test_analyze_input_serialization(self): """Test AnalyzeInput serialization.""" input_data = AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="average_speed", ) data = input_data.model_dump() assert "start_time" in data assert "analysis_type" in data ================================================ FILE: agent/tests/unit_test/video_analytics/test_tools_deep_coverage.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Deep coverage tests for video_analytics/tools inner functions.""" from unittest.mock import AsyncMock from unittest.mock import patch import pytest from vss_agents.video_analytics.tools import AnalyzeInput from vss_agents.video_analytics.tools import AverageSpeedsInput from vss_agents.video_analytics.tools import EmptyInput from vss_agents.video_analytics.tools import FovHistogramInput from vss_agents.video_analytics.tools import GetIncidentInput from vss_agents.video_analytics.tools import GetIncidentsInputBase from vss_agents.video_analytics.tools import GetSensorIdsInput from vss_agents.video_analytics.tools import VideoAnalyticsToolConfig from vss_agents.video_analytics.tools import video_analytics async def _setup(config, mock_builder, mock_es_client): """Setup and return functions dict.""" with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() fns_dict = await group.get_included_functions() result = {} for name, func_obj in fns_dict.items(): # Keys may be prefixed like "video_analytics__get_sensor_ids" or "video_analytics.get_sensor_ids" if "__" in name: short_name = name.split("__", 1)[-1] elif "." in name: short_name = name.split(".")[-1] else: short_name = name if hasattr(func_obj, "_ainvoke_fn") and func_obj._ainvoke_fn is not None: result[short_name] = func_obj._ainvoke_fn return result @pytest.fixture def config(): return VideoAnalyticsToolConfig( es_url="http://localhost:9200", embedding_model_name=None, vst_sensor_list_tool=None, ) @pytest.fixture def mock_builder(): return AsyncMock() @pytest.fixture def mock_es_client(): client = AsyncMock() client.get_by_id.return_value = { "calibration": { "sensors": [ { "id": "sensor-001", "place": [ {"value": "San Jose", "type": "city"}, {"value": "Intersection_A", "type": "intersection"}, ], }, { "id": "sensor-002", "place": [ {"value": "Mountain View", "type": "city"}, {"value": "Intersection_B", "type": "intersection"}, ], }, ] } } return client @pytest.mark.asyncio async def test_get_sensor_ids_with_place_filter(config, mock_builder, mock_es_client): fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_sensor_ids"](GetSensorIdsInput(place="Intersection_A")) assert isinstance(result, dict | list) @pytest.mark.asyncio async def test_get_sensor_ids_all(config, mock_builder, mock_es_client): fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_sensor_ids"](GetSensorIdsInput()) assert isinstance(result, dict | list) @pytest.mark.asyncio async def test_get_incident_found(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [{"id": "inc1", "category": "traffic"}] fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_incident"](GetIncidentInput(id="inc1")) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_incident_not_found(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [] fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_incident"](GetIncidentInput(id="nonexistent")) assert result == {} @pytest.mark.asyncio async def test_get_incident_with_includes(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [{"id": "inc1", "place": "SJ", "category": "jaywalking"}] fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_incident"](GetIncidentInput(id="inc1", includes=["place", "category"])) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_incidents_with_source_and_time(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [ {"id": "inc1", "timestamp": "2025-01-01T10:00:00.000Z", "end": "2025-01-01T10:05:00.000Z"}, {"id": "inc2", "timestamp": "2025-01-01T11:00:00.000Z", "end": "2025-01-01T11:05:00.000Z"}, ] fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_incidents"]( GetIncidentsInputBase( source="sensor-001", source_type="sensor", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", max_count=10, includes=["place"], ) ) assert isinstance(result, dict) assert "incidents" in result assert "has_more" in result @pytest.mark.asyncio async def test_get_incidents_has_more(config, mock_builder, mock_es_client): """Test pagination - when more results exist than max_count.""" mock_es_client.search.return_value = [{"id": f"inc{i}"} for i in range(3)] fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_incidents"](GetIncidentsInputBase(max_count=2)) assert result["has_more"] is True assert len(result["incidents"]) == 2 @pytest.mark.asyncio async def test_get_fov_histogram_with_data(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = { "eventsOverTime": { "buckets": [ { "key_as_string": "2025-01-01T00:00:00.000Z", "key": 1735689600000, "fov": { "searchAggFilter": { "objectType": { "buckets": [ {"key": "Person", "avgCount": {"value": 5.0}}, {"key": "Vehicle", "avgCount": {"value": 2.0}}, ] } } }, } ] } } fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_fov_histogram"]( FovHistogramInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", ) ) assert "histogram" in result assert "bucketSizeInSec" in result @pytest.mark.asyncio async def test_get_average_speeds_no_data(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = {"directions": {"buckets": []}} fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_average_speeds"]( AverageSpeedsInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source_type="sensor", ) ) assert result["metrics"] == [] @pytest.mark.asyncio async def test_get_average_speeds_null_value(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = { "directions": {"buckets": [{"key": "North", "averageSpeed": {"value": None}}]} } fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_average_speeds"]( AverageSpeedsInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source_type="sensor", ) ) assert result["metrics"][0]["averageSpeed"] == "0 mph" @pytest.mark.asyncio async def test_analyze_max_min_with_data(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [ {"timestamp": "2025-01-01T10:00:00.000Z", "end": "2025-01-01T10:05:00.000Z"}, {"timestamp": "2025-01-01T10:02:00.000Z", "end": "2025-01-01T10:07:00.000Z"}, {"timestamp": "2025-01-01T10:04:00.000Z", "end": "2025-01-01T10:09:00.000Z"}, ] fns = await _setup(config, mock_builder, mock_es_client) result = await fns["analyze"]( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", source="sensor-001", source_type="sensor", analysis_type="max_min_incidents", ) ) assert isinstance(result, str) assert "Maximum overlap" in result assert "Minimum overlap" in result @pytest.mark.asyncio async def test_analyze_average_speed_with_data(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = { "directions": { "buckets": [ {"key": "North", "averageSpeed": {"value": 25.0}}, {"key": "South", "averageSpeed": {"value": 30.0}}, ] } } fns = await _setup(config, mock_builder, mock_es_client) result = await fns["analyze"]( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="average_speed", ) ) assert "North" in result assert "South" in result @pytest.mark.asyncio async def test_analyze_avg_num_people_with_data(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = { "eventsOverTime": { "buckets": [ { "key_as_string": "2025-01-01T00:00:00.000Z", "key": 1735689600000, "fov": { "searchAggFilter": {"objectType": {"buckets": [{"key": "Person", "avgCount": {"value": 3.0}}]}} }, } ] } } fns = await _setup(config, mock_builder, mock_es_client) result = await fns["analyze"]( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="avg_num_people", ) ) assert "average" in result.lower() assert "3" in result @pytest.mark.asyncio async def test_analyze_avg_num_vehicles_with_data(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = { "eventsOverTime": { "buckets": [ { "key_as_string": "2025-01-01T00:00:00.000Z", "key": 1735689600000, "fov": { "searchAggFilter": {"objectType": {"buckets": [{"key": "Vehicle", "avgCount": {"value": 7.0}}]}} }, } ] } } fns = await _setup(config, mock_builder, mock_es_client) result = await fns["analyze"]( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="avg_num_vehicles", ) ) assert "average" in result.lower() assert "7" in result @pytest.mark.asyncio async def test_analyze_average_speed_no_data(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = {"directions": {"buckets": []}} fns = await _setup(config, mock_builder, mock_es_client) result = await fns["analyze"]( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="average_speed", ) ) assert "no speed data" in result.lower() @pytest.mark.asyncio async def test_get_places_from_cache(config, mock_builder, mock_es_client): fns = await _setup(config, mock_builder, mock_es_client) result = await fns["get_places"](EmptyInput()) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_places_empty_cache(mock_builder): config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", embedding_model_name=None, ) mock_es = AsyncMock() # Return None for calibration → empty cache mock_es.get_by_id.return_value = None fns = await _setup(config, mock_builder, mock_es) result = await fns["get_places"](EmptyInput()) assert isinstance(result, dict) assert result == {} ================================================ FILE: agent/tests/unit_test/video_analytics/test_tools_edge_cases.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Edge case tests for video_analytics/tools to cover remaining lines.""" from unittest.mock import AsyncMock from unittest.mock import patch import pytest from vss_agents.video_analytics.tools import AnalyzeInput from vss_agents.video_analytics.tools import EmptyInput from vss_agents.video_analytics.tools import GetSensorIdsInput from vss_agents.video_analytics.tools import VideoAnalyticsToolConfig from vss_agents.video_analytics.tools import video_analytics async def _setup(config, mock_builder, mock_es_client): with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() fns_dict = await group.get_included_functions() result = {} for name, func_obj in fns_dict.items(): # Keys may be prefixed like "video_analytics__get_sensor_ids" or "video_analytics.get_sensor_ids" if "__" in name: short_name = name.split("__", 1)[-1] elif "." in name: short_name = name.split(".")[-1] else: short_name = name if hasattr(func_obj, "_ainvoke_fn") and func_obj._ainvoke_fn is not None: result[short_name] = func_obj._ainvoke_fn return result @pytest.mark.asyncio async def test_get_sensor_ids_empty_cache(): """Test _get_sensor_ids when calibration returns empty → fallback path.""" config = VideoAnalyticsToolConfig(es_url="http://localhost:9200", embedding_model_name=None) mock_builder = AsyncMock() mock_es = AsyncMock() # Return calibration with empty sensors initially → cached_sensors is empty mock_es.get_by_id.return_value = {"calibration": {"sensors": []}} fns = await _setup(config, mock_builder, mock_es) # Now set mock for fallback get_by_id call inside _get_sensor_ids mock_es.get_by_id.return_value = {"calibration": {"sensors": [{"id": "s1"}]}} result = await fns["get_sensor_ids"](GetSensorIdsInput()) assert isinstance(result, dict | list) @pytest.mark.asyncio async def test_get_sensor_ids_empty_cache_no_calibration(): """Test _get_sensor_ids when calibration cache is empty AND fallback returns None.""" config = VideoAnalyticsToolConfig(es_url="http://localhost:9200", embedding_model_name=None) mock_builder = AsyncMock() mock_es = AsyncMock() mock_es.get_by_id.return_value = {"calibration": {"sensors": []}} fns = await _setup(config, mock_builder, mock_es) # Fallback also returns None mock_es.get_by_id.return_value = None result = await fns["get_sensor_ids"](GetSensorIdsInput()) assert isinstance(result, dict | list) @pytest.mark.asyncio async def test_get_places_empty_cache(): """Test _get_places when place_map is empty → fallback path.""" config = VideoAnalyticsToolConfig(es_url="http://localhost:9200", embedding_model_name=None) mock_builder = AsyncMock() mock_es = AsyncMock() # Empty sensors → empty place_map mock_es.get_by_id.return_value = {"calibration": {"sensors": []}} fns = await _setup(config, mock_builder, mock_es) # Fallback returns calibration with sensors mock_es.get_by_id.return_value = { "calibration": { "sensors": [ {"id": "s1", "place": [{"value": "City", "type": "city"}, {"value": "Street", "type": "intersection"}]} ] } } result = await fns["get_places"](EmptyInput()) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_places_empty_cache_no_data(): """Test _get_places when empty cache AND fallback returns None.""" config = VideoAnalyticsToolConfig(es_url="http://localhost:9200", embedding_model_name=None) mock_builder = AsyncMock() mock_es = AsyncMock() mock_es.get_by_id.return_value = {"calibration": {"sensors": []}} fns = await _setup(config, mock_builder, mock_es) mock_es.get_by_id.return_value = None result = await fns["get_places"](EmptyInput()) assert result == {} @pytest.mark.asyncio async def test_analyze_max_min_no_valid_timestamps(): """Test analyze when incidents have no valid timestamps (line 803).""" config = VideoAnalyticsToolConfig(es_url="http://localhost:9200", embedding_model_name=None) mock_builder = AsyncMock() mock_es = AsyncMock() mock_es.get_by_id.return_value = {"calibration": {"sensors": []}} # Return incidents without valid timestamps mock_es.search.return_value = [ {"timestamp": None, "end": None}, {"no_timestamp": True}, ] fns = await _setup(config, mock_builder, mock_es) result = await fns["analyze"]( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", source="sensor-001", source_type="sensor", analysis_type="max_min_incidents", ) ) assert "no valid incidents" in result.lower() or "no incidents" in result.lower() @pytest.mark.asyncio async def test_init_with_vst_sensor_list(): """Test initialization with VST sensor list tool configured.""" config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", embedding_model_name=None, vst_sensor_list_tool="vst_sensor_list", ) mock_builder = AsyncMock() mock_es = AsyncMock() mock_es.get_by_id.return_value = { "calibration": { "sensors": [ {"id": "s1", "place": [{"value": "SJ", "type": "city"}, {"value": "Int_A", "type": "intersection"}]} ] } } fns = await _setup(config, mock_builder, mock_es) # Test get_sensor_ids with VST tool - mock the builder.get_tool mock_vst_tool = AsyncMock() mock_vst_tool.ainvoke.return_value = '{"s1": {"name": "s1"}, "s2": {"name": "s2"}}' mock_builder.get_tool.return_value = mock_vst_tool result = await fns["get_sensor_ids"](GetSensorIdsInput()) assert isinstance(result, dict | list) ================================================ FILE: agent/tests/unit_test/video_analytics/test_tools_functions.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for video_analytics/tools.py inner functions with mocked ES client.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.video_analytics.tools import AnalyzeInput from vss_agents.video_analytics.tools import AverageSpeedsInput from vss_agents.video_analytics.tools import EmptyInput from vss_agents.video_analytics.tools import FovHistogramInput from vss_agents.video_analytics.tools import GetIncidentInput from vss_agents.video_analytics.tools import GetIncidentsInputBase from vss_agents.video_analytics.tools import GetIncidentsInputWithVLM from vss_agents.video_analytics.tools import GetSensorIdsInput from vss_agents.video_analytics.tools import VideoAnalyticsToolConfig from vss_agents.video_analytics.tools import video_analytics # Access the unwrapped async generator function _video_analytics_unwrapped = video_analytics.__wrapped__ class MockESClient: """Mock ES client that returns controlled test data.""" def __init__(self, es_url, index_prefix=""): self.es_url = es_url self.index_prefix = index_prefix self._search_results = [] self._aggregate_results = {} self._get_by_id_results = {} def set_search_results(self, results): self._search_results = results def set_aggregate_results(self, results): self._aggregate_results = results def set_get_by_id_results(self, results): self._get_by_id_results = results async def get_by_id(self, index_key, doc_id): return self._get_by_id_results.get(f"{index_key}:{doc_id}") async def search(self, index_key, query_body, size=100, sort=None, source_includes=None, source_excludes=None): return self._search_results async def aggregate(self, index_key, query_body, aggs): return self._aggregate_results async def close(self): pass @pytest.fixture def mock_builder(): builder = MagicMock() builder.get_tool = AsyncMock(return_value=MagicMock()) return builder @pytest.fixture def sample_calibration_data(): return { "calibration": { "sensors": [ {"id": "sensor-001", "place": [{"value": "San Jose"}, {"value": "Main Street"}]}, {"id": "sensor-002", "place": [{"value": "San Jose"}, {"value": "Oak Avenue"}]}, {"id": "sensor-003", "place": [{"value": "Mountain View"}, {"value": "Castro Street"}]}, ] } } @pytest.fixture def sample_incidents(): return [ { "Id": "incident-001", "timestamp": "2025-01-15T10:00:00.000Z", "end": "2025-01-15T10:05:00.000Z", "sensorId": "sensor-001", }, { "Id": "incident-002", "timestamp": "2025-01-15T10:10:00.000Z", "end": "2025-01-15T10:15:00.000Z", "sensorId": "sensor-001", }, ] async def invoke_function(group, name, input_obj): """Helper to get and invoke a function from the group by name.""" all_funcs = await group.get_all_functions() # NAT 1.4.0 uses double underscores as separator in function names full_name = f"video_analytics__{name}" func_impl = all_funcs.get(full_name) if func_impl is None: raise ValueError(f"Function {name} not found in {list(all_funcs.keys())}") return await func_impl.ainvoke(input_obj) class TestVideoAnalyticsFunctions: """Test the inner functions of video_analytics.""" @pytest.mark.asyncio async def test_get_incident_found(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_search_results( [ { "Id": "incident-123", "timestamp": "2025-01-15T10:00:00.000Z", "end": "2025-01-15T10:05:00.000Z", "sensorId": "sensor-001", } ] ) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function(group, "get_incident", GetIncidentInput(id="incident-123")) assert result["Id"] == "incident-123" break @pytest.mark.asyncio async def test_get_incident_not_found(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_search_results([]) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function(group, "get_incident", GetIncidentInput(id="nonexistent")) assert result == {} break @pytest.mark.asyncio async def test_get_incidents_basic(self, mock_builder, sample_calibration_data, sample_incidents): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_search_results(sample_incidents) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function(group, "get_incidents", GetIncidentsInputBase()) assert "incidents" in result assert len(result["incidents"]) == 2 break @pytest.mark.asyncio async def test_get_incidents_with_source(self, mock_builder, sample_calibration_data, sample_incidents): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_search_results(sample_incidents) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "get_incidents", GetIncidentsInputBase( source="sensor-001", source_type="sensor", start_time="2025-01-15T00:00:00.000Z", end_time="2025-01-15T23:59:59.000Z", ), ) assert "incidents" in result break @pytest.mark.asyncio async def test_get_incidents_has_more(self, mock_builder, sample_calibration_data): many_incidents = [ {"Id": f"i-{i}", "timestamp": "2025-01-15T10:00:00.000Z", "end": "2025-01-15T10:05:00.000Z"} for i in range(11) ] mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_search_results(many_incidents) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function(group, "get_incidents", GetIncidentsInputBase(max_count=10)) assert result["has_more"] is True assert len(result["incidents"]) == 10 break @pytest.mark.asyncio async def test_get_sensor_ids_all(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function(group, "get_sensor_ids", GetSensorIdsInput()) assert "sensor-001" in result assert "sensor-002" in result break @pytest.mark.asyncio async def test_get_sensor_ids_with_place(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function(group, "get_sensor_ids", GetSensorIdsInput(place="Main Street")) assert result == ["sensor-001"] break @pytest.mark.asyncio async def test_get_places(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function(group, "get_places", EmptyInput()) assert "San Jose" in result assert "Mountain View" in result break @pytest.mark.asyncio async def test_get_fov_histogram(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_aggregate_results( { "eventsOverTime": { "buckets": [ { "key": 1736935200000, "key_as_string": "2025-01-15T10:00:00.000Z", "fov": { "searchAggFilter": { "objectType": {"buckets": [{"key": "Person", "avgCount": {"value": 5.0}}]} } }, } ] } } ) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "get_fov_histogram", FovHistogramInput( source="sensor-001", start_time="2025-01-15T10:00:00.000Z", end_time="2025-01-15T11:00:00.000Z", bucket_count=10, ), ) assert "bucketSizeInSec" in result assert "histogram" in result break @pytest.mark.asyncio async def test_get_average_speeds(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_aggregate_results({"directions": {"buckets": [{"key": "North", "averageSpeed": {"value": 25.5}}]}}) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "get_average_speeds", AverageSpeedsInput( source="sensor-001", start_time="2025-01-15T10:00:00.000Z", end_time="2025-01-15T11:00:00.000Z", source_type="sensor", ), ) assert "metrics" in result assert "25 mph" in result["metrics"][0]["averageSpeed"] break @pytest.mark.asyncio async def test_analyze_max_min_incidents(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_search_results( [ {"timestamp": "2025-01-15T10:00:00.000Z", "end": "2025-01-15T10:10:00.000Z"}, {"timestamp": "2025-01-15T10:05:00.000Z", "end": "2025-01-15T10:15:00.000Z"}, ] ) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "analyze", AnalyzeInput( start_time="2025-01-15T00:00:00.000Z", end_time="2025-01-15T23:59:59.000Z", source="sensor-001", source_type="sensor", analysis_type="max_min_incidents", ), ) assert "Maximum overlap" in result break @pytest.mark.asyncio async def test_analyze_no_incidents(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_search_results([]) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "analyze", AnalyzeInput( start_time="2025-01-15T00:00:00.000Z", end_time="2025-01-15T23:59:59.000Z", source="sensor-001", source_type="sensor", analysis_type="max_min_incidents", ), ) assert "no incidents" in result.lower() break @pytest.mark.asyncio async def test_analyze_average_speed(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_aggregate_results({"directions": {"buckets": [{"key": "North", "averageSpeed": {"value": 25.0}}]}}) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "analyze", AnalyzeInput( start_time="2025-01-15T00:00:00.000Z", end_time="2025-01-15T23:59:59.000Z", source="sensor-001", source_type="sensor", analysis_type="average_speed", ), ) assert "speed" in result.lower() break @pytest.mark.asyncio async def test_analyze_avg_num_people(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_aggregate_results( { "eventsOverTime": { "buckets": [ { "key": 1736935200000, "key_as_string": "2025-01-15T10:00:00.000Z", "fov": { "searchAggFilter": { "objectType": {"buckets": [{"key": "Person", "avgCount": {"value": 5.0}}]} } }, } ] } } ) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "analyze", AnalyzeInput( start_time="2025-01-15T10:00:00.000Z", end_time="2025-01-15T11:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="avg_num_people", ), ) assert "people" in result.lower() break @pytest.mark.asyncio async def test_analyze_avg_num_vehicles(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_aggregate_results( { "eventsOverTime": { "buckets": [ { "key": 1736935200000, "key_as_string": "2025-01-15T10:00:00.000Z", "fov": { "searchAggFilter": { "objectType": {"buckets": [{"key": "Vehicle", "avgCount": {"value": 3.0}}]} } }, } ] } } ) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "analyze", AnalyzeInput( start_time="2025-01-15T10:00:00.000Z", end_time="2025-01-15T11:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="avg_num_vehicles", ), ) assert "vehicle" in result.lower() break class TestVideoAnalyticsWithVLM: """Test with VLM verification enabled.""" @pytest.mark.asyncio async def test_get_incidents_vlm_verified(self, mock_builder, sample_calibration_data, sample_incidents): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) mock_es.set_search_results(sample_incidents) config = VideoAnalyticsToolConfig(embedding_model_name=None, vlm_verified=True) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function( group, "get_incidents", GetIncidentsInputWithVLM(vlm_verdict="confirmed") ) assert "incidents" in result break class TestVideoAnalyticsNoCalibration: """Test when calibration data is missing.""" @pytest.mark.asyncio async def test_no_calibration_data(self, mock_builder): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({}) config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): result = await invoke_function(group, "get_places", EmptyInput()) assert result == {} break @pytest.mark.asyncio async def test_calibration_error(self, mock_builder): mock_es = MockESClient("http://localhost:9200") async def raise_error(*a, **kw): raise Exception("ES down") mock_es.get_by_id = raise_error config = VideoAnalyticsToolConfig(embedding_model_name=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): assert group is not None break class TestVideoAnalyticsIncludeConfig: """Test include configuration.""" @pytest.mark.asyncio async def test_include_only_get_incidents(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) config = VideoAnalyticsToolConfig(embedding_model_name=None, include=["get_incidents"]) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): all_funcs = await group.get_all_functions() assert "video_analytics__get_incidents" in all_funcs assert "video_analytics__get_incident" not in all_funcs break @pytest.mark.asyncio async def test_include_empty(self, mock_builder, sample_calibration_data): mock_es = MockESClient("http://localhost:9200") mock_es.set_get_by_id_results({"calibration:calibration": sample_calibration_data}) config = VideoAnalyticsToolConfig(embedding_model_name=None, include=[]) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): async for group in _video_analytics_unwrapped(config, mock_builder): all_funcs = await group.get_all_functions() assert len(all_funcs) == 0 break ================================================ FILE: agent/tests/unit_test/video_analytics/test_tools_inner.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for video_analytics/tools generator initialization.""" from unittest.mock import AsyncMock from unittest.mock import patch import pytest from vss_agents.video_analytics.tools import VideoAnalyticsToolConfig from vss_agents.video_analytics.tools import video_analytics class TestVideoAnalyticsInitialization: """Test video_analytics generator initialization with different configs.""" @pytest.fixture def mock_builder(self): return AsyncMock() @pytest.mark.asyncio async def test_init_with_calibration(self, mock_builder): config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", embedding_model_name=None, ) mock_es_client = AsyncMock() mock_es_client.get_by_id.return_value = { "calibration": { "sensors": [ {"id": "sensor-001", "place": [{"value": "San Jose"}, {"value": "Intersection_A"}]}, ] } } with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() assert group is not None @pytest.mark.asyncio async def test_init_calibration_failure(self, mock_builder): config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", embedding_model_name=None, ) mock_es_client = AsyncMock() mock_es_client.get_by_id.side_effect = RuntimeError("ES unavailable") with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() assert group is not None @pytest.mark.asyncio async def test_init_calibration_none(self, mock_builder): config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", embedding_model_name=None, ) mock_es_client = AsyncMock() mock_es_client.get_by_id.return_value = None with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() assert group is not None @pytest.mark.asyncio async def test_init_with_embeddings(self, mock_builder): config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", embedding_model_name="sentence-transformers/all-MiniLM-L6-v2", ) mock_es_client = AsyncMock() mock_es_client.get_by_id.return_value = { "calibration": { "sensors": [ {"id": "sensor-001", "place": [{"value": "San Jose"}, {"value": "Intersection_A"}]}, {"id": "sensor-002", "place": [{"value": ""}, {"value": "Intersection_B"}]}, ] } } with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() assert group is not None @pytest.mark.asyncio async def test_init_with_vlm_verified(self, mock_builder): config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", vlm_verified=True, embedding_model_name=None, ) mock_es_client = AsyncMock() mock_es_client.get_by_id.return_value = {"calibration": {"sensors": []}} with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() assert group is not None @pytest.mark.asyncio async def test_init_with_vst_sensor_tool(self, mock_builder): config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", vst_sensor_list_tool="vst_sensor_list", embedding_model_name=None, ) mock_es_client = AsyncMock() mock_es_client.get_by_id.return_value = {"calibration": {"sensors": []}} with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() assert group is not None @pytest.mark.asyncio async def test_init_custom_include(self, mock_builder): config = VideoAnalyticsToolConfig( es_url="http://localhost:9200", include=["get_incidents", "get_sensor_ids"], embedding_model_name=None, ) mock_es_client = AsyncMock() mock_es_client.get_by_id.return_value = {"calibration": {"sensors": []}} with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() assert group is not None ================================================ FILE: agent/tests/unit_test/video_analytics/test_tools_inner_fns.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for video_analytics/tools inner functions.""" from unittest.mock import AsyncMock from unittest.mock import patch import pytest from vss_agents.video_analytics.tools import AnalyzeInput from vss_agents.video_analytics.tools import AverageSpeedsInput from vss_agents.video_analytics.tools import EmptyInput from vss_agents.video_analytics.tools import FovHistogramInput from vss_agents.video_analytics.tools import GetIncidentInput from vss_agents.video_analytics.tools import GetIncidentsInputBase from vss_agents.video_analytics.tools import GetSensorIdsInput from vss_agents.video_analytics.tools import VideoAnalyticsToolConfig from vss_agents.video_analytics.tools import video_analytics async def _get_fns(config, mock_builder, mock_es_client): """Get raw callable functions from the group, keyed by name.""" with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es_client): gen = video_analytics.__wrapped__(config, mock_builder) group = await gen.__anext__() fns_dict = await group.get_included_functions() # Extract the raw callable from LambdaFunction._ainvoke_fn # Keys are prefixed like "video_analytics__get_sensor_ids" or "video_analytics.get_sensor_ids" result = {} for name, func_obj in fns_dict.items(): if "__" in name: short_name = name.split("__", 1)[-1] elif "." in name: short_name = name.split(".")[-1] else: short_name = name if hasattr(func_obj, "_ainvoke_fn") and func_obj._ainvoke_fn is not None: result[short_name] = func_obj._ainvoke_fn return result @pytest.fixture def config(): return VideoAnalyticsToolConfig( es_url="http://localhost:9200", embedding_model_name=None, vst_sensor_list_tool=None, ) @pytest.fixture def mock_builder(): return AsyncMock() @pytest.fixture def mock_es_client(): client = AsyncMock() client.get_by_id.return_value = { "calibration": { "sensors": [ { "id": "sensor-001", "place": [ {"value": "San Jose", "type": "city"}, {"value": "Intersection_A", "type": "intersection"}, ], }, { "id": "sensor-002", "place": [ {"value": "Mountain View", "type": "city"}, {"value": "Intersection_B", "type": "intersection"}, ], }, ] } } return client @pytest.mark.asyncio async def test_get_sensor_ids_fn(config, mock_builder, mock_es_client): fns = await _get_fns(config, mock_builder, mock_es_client) assert "get_sensor_ids" in fns, f"Expected get_sensor_ids in {list(fns.keys())}" result = await fns["get_sensor_ids"](GetSensorIdsInput()) assert isinstance(result, dict | list) @pytest.mark.asyncio async def test_get_places_fn(config, mock_builder, mock_es_client): fns = await _get_fns(config, mock_builder, mock_es_client) if "get_places" in fns: fn = fns["get_places"] result = await fn(EmptyInput()) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_incident_fn(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [{"id": "inc1", "category": "test"}] fns = await _get_fns(config, mock_builder, mock_es_client) if "get_incident" in fns: fn = fns["get_incident"] result = await fn(GetIncidentInput(id="inc1")) assert isinstance(result, dict | list) @pytest.mark.asyncio async def test_get_incidents_fn(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [{"id": "inc1"}] fns = await _get_fns(config, mock_builder, mock_es_client) if "get_incidents" in fns: fn = fns["get_incidents"] result = await fn(GetIncidentsInputBase()) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_incidents_with_sensor(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [] fns = await _get_fns(config, mock_builder, mock_es_client) if "get_incidents" in fns: fn = fns["get_incidents"] result = await fn( GetIncidentsInputBase( source="sensor-001", source_type="sensor", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", ) ) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_incidents_with_place(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [] fns = await _get_fns(config, mock_builder, mock_es_client) if "get_incidents" in fns: fn = fns["get_incidents"] result = await fn(GetIncidentsInputBase(source="San Jose", source_type="place")) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_fov_histogram_fn(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = {} fns = await _get_fns(config, mock_builder, mock_es_client) if "get_fov_histogram" in fns: fn = fns["get_fov_histogram"] result = await fn( FovHistogramInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", ) ) assert isinstance(result, dict) @pytest.mark.asyncio async def test_get_average_speeds_fn(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = { "directions": {"buckets": [{"key": "North", "averageSpeed": {"value": 25.0}}]} } fns = await _get_fns(config, mock_builder, mock_es_client) if "get_average_speeds" in fns: fn = fns["get_average_speeds"] result = await fn( AverageSpeedsInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source_type="sensor", ) ) assert isinstance(result, dict) @pytest.mark.asyncio async def test_analyze_max_min_incidents(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [ {"timestamp": "2025-01-01T10:00:00.000Z", "end": "2025-01-01T10:05:00.000Z"}, {"timestamp": "2025-01-01T10:03:00.000Z", "end": "2025-01-01T10:08:00.000Z"}, ] fns = await _get_fns(config, mock_builder, mock_es_client) if "analyze" in fns: fn = fns["analyze"] result = await fn( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", source="sensor-001", source_type="sensor", analysis_type="max_min_incidents", ) ) assert isinstance(result, str) assert "incident" in result.lower() @pytest.mark.asyncio async def test_analyze_no_incidents(config, mock_builder, mock_es_client): mock_es_client.search.return_value = [] fns = await _get_fns(config, mock_builder, mock_es_client) if "analyze" in fns: fn = fns["analyze"] result = await fn( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T23:59:59.000Z", source="sensor-001", source_type="sensor", analysis_type="max_min_incidents", ) ) assert isinstance(result, str) assert "no incidents" in result.lower() @pytest.mark.asyncio async def test_analyze_average_speed(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = { "directions": {"buckets": [{"key": "East", "averageSpeed": {"value": 30.0}}]} } fns = await _get_fns(config, mock_builder, mock_es_client) if "analyze" in fns: fn = fns["analyze"] result = await fn( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="average_speed", ) ) assert isinstance(result, str) @pytest.mark.asyncio async def test_analyze_avg_num_people(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = {} fns = await _get_fns(config, mock_builder, mock_es_client) if "analyze" in fns: fn = fns["analyze"] result = await fn( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="avg_num_people", ) ) assert isinstance(result, str) @pytest.mark.asyncio async def test_analyze_avg_num_vehicles(config, mock_builder, mock_es_client): mock_es_client.aggregate.return_value = {} fns = await _get_fns(config, mock_builder, mock_es_client) if "analyze" in fns: fn = fns["analyze"] result = await fn( AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type="avg_num_vehicles", ) ) assert isinstance(result, str) ================================================ FILE: agent/tests/unit_test/video_analytics/test_tools_integration.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Integration-style unit tests for video_analytics/tools module with mocked dependencies.""" from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from vss_agents.video_analytics.tools import AnalyzeInput from vss_agents.video_analytics.tools import AverageSpeedsInput from vss_agents.video_analytics.tools import FovHistogramInput from vss_agents.video_analytics.tools import GetIncidentInput from vss_agents.video_analytics.tools import GetIncidentsInputBase from vss_agents.video_analytics.tools import GetIncidentsInputWithVLM from vss_agents.video_analytics.tools import GetSensorIdsInput from vss_agents.video_analytics.tools import VideoAnalyticsToolConfig from vss_agents.video_analytics.tools import video_analytics class MockESClient: """Mock ES client for testing.""" def __init__(self, url, prefix): self.url = url self.prefix = prefix self._calibration_data = { "calibration": { "sensors": [ {"id": "sensor-001", "place": [{"value": "San Jose"}, {"value": "Intersection_A"}]}, {"id": "sensor-002", "place": [{"value": "San Jose"}, {"value": "Intersection_B"}]}, ] } } async def get_by_id(self, index_key, doc_id): if index_key == "calibration" and doc_id == "calibration": return self._calibration_data return None async def search(self, index_key, query, size=10): return {"hits": {"hits": []}} async def scroll(self, scroll_id): return {"hits": {"hits": []}} @pytest.fixture def mock_es_client(): """Create a mock ES client.""" return MockESClient("http://localhost:9200", "") @pytest.fixture def mock_builder(): """Create a mock builder.""" builder = MagicMock() builder.get_tool = AsyncMock() return builder @pytest.fixture def config(): """Create a test config.""" return VideoAnalyticsToolConfig( es_url="http://localhost:9200", index_prefix="", vlm_verified=False, embedding_model_name=None, # Disable embedding model for simpler tests ) @pytest.fixture def config_with_vlm(): """Create a test config with VLM verified enabled.""" return VideoAnalyticsToolConfig( es_url="http://localhost:9200", index_prefix="test-", vlm_verified=True, embedding_model_name=None, ) class TestVideoAnalyticsConfig: """Test video analytics configuration variants.""" def test_config_with_all_options(self): """Test config with all options set.""" config = VideoAnalyticsToolConfig( es_url="http://es:9200", index_prefix="prod-", vlm_verified=True, vst_sensor_list_tool="vst_sensor_list", embedding_model_name="sentence-transformers/all-MiniLM-L6-v2", include=["get_incidents", "get_sensor_ids"], ) assert config.es_url == "http://es:9200" assert config.index_prefix == "prod-" assert config.vlm_verified is True assert config.vst_sensor_list_tool == "vst_sensor_list" assert len(config.include) == 2 def test_config_minimal(self): """Test minimal config.""" config = VideoAnalyticsToolConfig() assert config.es_url == "http://localhost:9200" assert config.index_prefix == "" assert config.vlm_verified is False class TestInputModelEdgeCases: """Test edge cases in input models.""" def test_get_incidents_with_all_fields_populated(self): """Test GetIncidentsInputBase with all fields.""" input_data = GetIncidentsInputBase( source="Main Street", source_type="place", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-31T23:59:59.999Z", max_count=100, includes=["place", "category", "type", "sensorId", "timestamp", "end"], ) assert input_data.max_count == 100 assert len(input_data.includes) == 6 def test_get_incidents_vlm_with_all_verdicts(self): """Test all VLM verdict options.""" verdicts = ["all", "confirmed", "rejected", "verification-failed", "not-confirmed"] for verdict in verdicts: input_data = GetIncidentsInputWithVLM(vlm_verdict=verdict) assert input_data.vlm_verdict == verdict def test_fov_histogram_bucket_counts(self): """Test various bucket count values.""" bucket_counts = [1, 5, 10, 20, 50, 100] for count in bucket_counts: input_data = FovHistogramInput( source="sensor-001", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", bucket_count=count, ) assert input_data.bucket_count == count def test_average_speeds_source_types(self): """Test both source types for average speeds.""" for source_type in ["sensor", "place"]: input_data = AverageSpeedsInput( source="test-source", start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source_type=source_type, ) assert input_data.source_type == source_type def test_analyze_all_types(self): """Test all analysis types.""" types = ["max_min_incidents", "average_speed", "avg_num_people", "avg_num_vehicles"] for analysis_type in types: input_data = AnalyzeInput( start_time="2025-01-01T00:00:00.000Z", end_time="2025-01-01T01:00:00.000Z", source="sensor-001", source_type="sensor", analysis_type=analysis_type, ) assert input_data.analysis_type == analysis_type class TestGetSensorIdsInput: """Additional tests for GetSensorIdsInput.""" def test_place_filter_variations(self): """Test various place filter values.""" places = ["Main Street", "Intersection A & B", "San Jose, CA", "123-456"] for place in places: input_data = GetSensorIdsInput(place=place) assert input_data.place == place def test_none_place(self): """Test with None place filter.""" input_data = GetSensorIdsInput(place=None) assert input_data.place is None class TestGetIncidentInput: """Additional tests for GetIncidentInput.""" def test_various_id_formats(self): """Test various incident ID formats.""" ids = ["123", "incident-001", "UUID-abc-123", "a" * 100] for id_val in ids: input_data = GetIncidentInput(id=id_val) assert input_data.id == id_val def test_includes_variations(self): """Test various includes field combinations.""" includes_list = [ ["place"], ["place", "category"], ["place", "category", "type", "sensorId"], [], ] for includes in includes_list: input_data = GetIncidentInput(id="test", includes=includes if includes else None) if includes: assert input_data.includes == includes else: assert input_data.includes is None class TestConfigInclude: """Test config include list handling.""" def test_include_all_functions(self): """Test config with all functions included.""" config = VideoAnalyticsToolConfig( include=[ "get_incident", "get_incidents", "get_sensor_ids", "get_places", "get_fov_histogram", "get_average_speeds", "analyze", ] ) assert len(config.include) == 7 def test_include_single_function(self): """Test config with single function.""" config = VideoAnalyticsToolConfig(include=["get_incidents"]) assert config.include == ["get_incidents"] def test_include_empty_list(self): """Test config with empty include list.""" config = VideoAnalyticsToolConfig(include=[]) assert config.include == [] class TestVideoAnalyticsAsyncGenerator: """Test the video_analytics async generator function with mocked dependencies.""" @pytest.mark.asyncio async def test_video_analytics_initialization(self, config, mock_builder): """Test video_analytics function can be initialized with mocked ES client.""" mock_es = AsyncMock() mock_es.get_by_id = AsyncMock( return_value={ "calibration": { "sensors": [{"id": "sensor-001", "place": [{"value": "City"}, {"value": "Intersection"}]}] } } ) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): # Get the async generator from the decorated function # The decorator wraps it, so we need to handle that gen = video_analytics(config, mock_builder) # The generator should yield FunctionGroup try: async for group in gen: assert group is not None break except (StopAsyncIteration, TypeError): # If the decorator changes behavior, this is expected pass @pytest.mark.asyncio async def test_video_analytics_with_calibration_error(self, config, mock_builder): """Test video_analytics handles calibration fetch errors gracefully.""" mock_es = AsyncMock() mock_es.get_by_id = AsyncMock(side_effect=Exception("ES connection failed")) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): gen = video_analytics(config, mock_builder) try: async for group in gen: # Should still yield a group even if calibration fails assert group is not None break except (StopAsyncIteration, TypeError): pass @pytest.mark.asyncio async def test_video_analytics_with_empty_calibration(self, config, mock_builder): """Test video_analytics with empty calibration data.""" mock_es = AsyncMock() mock_es.get_by_id = AsyncMock(return_value=None) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): gen = video_analytics(config, mock_builder) try: async for group in gen: assert group is not None break except (StopAsyncIteration, TypeError): pass @pytest.mark.asyncio async def test_video_analytics_with_embedding_model(self, mock_builder): """Test video_analytics with embedding model enabled.""" config = VideoAnalyticsToolConfig(embedding_model_name="test-model") mock_es = AsyncMock() mock_es.get_by_id = AsyncMock( return_value={ "calibration": { "sensors": [{"id": "sensor-001", "place": [{"value": "City"}, {"value": "Intersection"}]}] } } ) mock_embedding_model = MagicMock() mock_embedding_model.encode_batch = MagicMock(return_value=[[0.1, 0.2, 0.3]]) with patch("vss_agents.video_analytics.tools.ESClient", return_value=mock_es): with patch("vss_agents.video_analytics.embeddings.EmbeddingModel", return_value=mock_embedding_model): gen = video_analytics(config, mock_builder) try: async for group in gen: assert group is not None break except (StopAsyncIteration, TypeError, Exception): # Expected if mocking is incomplete pass ================================================ FILE: agent/tests/unit_test/video_analytics/test_utils.py ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for vss_agents/video_analytics/utils.py.""" from datetime import UTC from datetime import datetime import pytest from vss_agents.video_analytics.utils import build_place_map from vss_agents.video_analytics.utils import build_sensor_map from vss_agents.video_analytics.utils import compute_bucket_size_seconds from vss_agents.video_analytics.utils import create_empty_histogram_buckets from vss_agents.video_analytics.utils import create_events_from_incidents from vss_agents.video_analytics.utils import parse_vst_sensor_list_response from vss_agents.video_analytics.utils import sweep_overlapping_incidents from vss_agents.video_analytics.utils import validate_iso_timestamp class TestValidateIsoTimestamp: """Tests for validate_iso_timestamp function.""" def test_valid_timestamp(self): """Test valid ISO timestamp.""" timestamp = "2022-08-25T00:00:10.000Z" result = validate_iso_timestamp(timestamp) assert result == timestamp def test_invalid_format_no_milliseconds(self): """Test invalid format without milliseconds.""" with pytest.raises(ValueError, match="Invalid timestamp format"): validate_iso_timestamp("2022-08-25T00:00:10Z") def test_invalid_format_no_z(self): """Test invalid format without Z.""" with pytest.raises(ValueError, match="Invalid timestamp format"): validate_iso_timestamp("2022-08-25T00:00:10.000") def test_invalid_date_values(self): """Test invalid date values.""" with pytest.raises(ValueError): validate_iso_timestamp("2022-13-25T00:00:10.000Z") # Invalid month class TestBuildSensorMap: """Tests for build_sensor_map function.""" def test_build_sensor_map_basic(self, sample_sensors): """Test building sensor map with basic data.""" result = build_sensor_map(sample_sensors) assert "San Jose" in result assert "Intersection_A" in result["San Jose"] assert "sensor-001" in result["San Jose"]["Intersection_A"] def test_build_sensor_map_multiple_cities(self, sample_sensors): """Test building sensor map with multiple cities.""" result = build_sensor_map(sample_sensors) assert "San Jose" in result assert "Mountain View" in result def test_build_sensor_map_missing_place(self): """Test building sensor map with missing place field.""" sensors = [{"id": "sensor-001"}] # No place field result = build_sensor_map(sensors) assert result == {} def test_build_sensor_map_malformed_place(self): """Test building sensor map with malformed place field.""" sensors = [{"id": "sensor-001", "place": []}] # Empty place result = build_sensor_map(sensors) assert result == {} def test_build_sensor_map_missing_id(self): """Test building sensor map with missing id.""" sensors = [{"place": [{"value": "City"}, {"value": "Intersection"}]}] result = build_sensor_map(sensors) # The function creates the structure but with empty sensor list when id is missing assert "City" in result assert "Intersection" in result["City"] assert result["City"]["Intersection"] == [] # Empty because no id def test_build_sensor_map_missing_city_value(self): """Test building sensor map with missing city value (covers line 84).""" sensors = [{"id": "sensor-001", "place": [{"value": None}, {"value": "Intersection"}]}] result = build_sensor_map(sensors) assert result == {} def test_build_sensor_map_missing_intersection_value(self): """Test building sensor map with missing intersection value (covers line 85).""" sensors = [{"id": "sensor-001", "place": [{}, {"value": "Intersection"}]}] result = build_sensor_map(sensors) assert result == {} class TestBuildPlaceMap: """Tests for build_place_map function.""" def test_build_place_map_basic(self, sample_sensors): """Test building place map.""" result = build_place_map(sample_sensors) assert "San Jose" in result assert "Intersection_A" in result["San Jose"] assert "Intersection_B" in result["San Jose"] def test_build_place_map_sorted(self, sample_sensors): """Test that place map intersections are sorted.""" result = build_place_map(sample_sensors) # Intersections should be sorted alphabetically san_jose_intersections = result["San Jose"] assert san_jose_intersections == sorted(san_jose_intersections) def test_build_place_map_no_duplicates(self): """Test that place map has no duplicate intersections.""" sensors = [ {"id": "s1", "place": [{"value": "City"}, {"value": "Int1"}]}, {"id": "s2", "place": [{"value": "City"}, {"value": "Int1"}]}, # Same intersection ] result = build_place_map(sensors) assert len(result["City"]) == 1 def test_build_place_map_missing_place(self): """Test building place map with missing place field (covers line 127).""" sensors = [{"id": "sensor-001"}] # No place field result = build_place_map(sensors) assert result == {} def test_build_place_map_malformed_place(self): """Test building place map with malformed place (covers line 128).""" sensors = [{"id": "sensor-001", "place": []}] # Empty place result = build_place_map(sensors) assert result == {} def test_build_place_map_missing_city_value(self): """Test building place map with missing city value (covers line 134).""" sensors = [{"id": "sensor-001", "place": [{"value": None}, {"value": "Intersection"}]}] result = build_place_map(sensors) assert result == {} def test_build_place_map_missing_intersection_value(self): """Test building place map with missing intersection value (covers line 135).""" sensors = [{"id": "sensor-001", "place": [{}, {"value": "Intersection"}]}] result = build_place_map(sensors) assert result == {} class TestParseVstSensorListResponse: """Tests for parse_vst_sensor_list_response function.""" def test_parse_empty_response(self): """Test parsing empty response.""" result = parse_vst_sensor_list_response("") assert result == set() def test_parse_dict_response(self): """Test parsing dictionary response.""" response = '{"sensor1": {"name": "Camera1"}, "sensor2": {"name": "Camera2"}}' result = parse_vst_sensor_list_response(response) assert "Camera1" in result assert "Camera2" in result def test_parse_quoted_response(self): """Test parsing quoted response.""" response = '"{\\"sensor1\\": {\\"name\\": \\"Camera1\\"}}"' # This tests handling of wrapper quotes parse_vst_sensor_list_response(response) # May return empty if parsing fails def test_parse_invalid_json(self): """Test parsing invalid JSON.""" result = parse_vst_sensor_list_response("not valid json") assert result == set() class TestComputeBucketSizeSeconds: """Tests for compute_bucket_size_seconds function.""" def test_compute_bucket_size_hour(self): """Test computing bucket size for 1 hour range.""" start = "2022-08-25T00:00:00.000Z" end = "2022-08-25T01:00:00.000Z" result = compute_bucket_size_seconds(start, end, bucket_count=10) assert result == 360 # 3600 / 10 def test_compute_bucket_size_minimum(self): """Test that bucket size is at least 1 second.""" start = "2022-08-25T00:00:00.000Z" end = "2022-08-25T00:00:01.000Z" result = compute_bucket_size_seconds(start, end, bucket_count=100) assert result >= 1 def test_compute_bucket_size_invalid_count(self): """Test with invalid bucket count.""" start = "2022-08-25T00:00:00.000Z" end = "2022-08-25T01:00:00.000Z" with pytest.raises(ValueError): compute_bucket_size_seconds(start, end, bucket_count=0) class TestCreateEmptyHistogramBuckets: """Tests for create_empty_histogram_buckets function.""" def test_create_buckets(self): """Test creating histogram buckets.""" start = "2022-08-25T00:00:00.000Z" end = "2022-08-25T00:01:00.000Z" result = create_empty_histogram_buckets(start, end, bucket_size_sec=30) assert len(result) >= 1 assert "start" in result[0] assert "end" in result[0] assert "objects" in result[0] def test_create_buckets_invalid_size(self): """Test with invalid bucket size.""" start = "2022-08-25T00:00:00.000Z" end = "2022-08-25T01:00:00.000Z" with pytest.raises(ValueError): create_empty_histogram_buckets(start, end, bucket_size_sec=0) def test_create_buckets_truncated_last_bucket(self): """Test that last bucket is truncated to end time (covers line 243).""" start = "2022-08-25T00:00:00.000Z" # End time doesn't align with bucket size end = "2022-08-25T00:00:45.000Z" # 45 seconds, bucket size 30 result = create_empty_histogram_buckets(start, end, bucket_size_sec=30) # Should have 2 buckets: 0-30s and 30-45s (truncated) assert len(result) == 2 # Last bucket should end at exactly the end time assert result[-1]["end"] == end class TestCreateEventsFromIncidents: """Tests for create_events_from_incidents function.""" def test_create_events_basic(self, sample_incidents): """Test creating events from incidents.""" events, count = create_events_from_incidents(sample_incidents) assert count == 2 assert len(events) == 4 # 2 start + 2 end events def test_create_events_empty(self): """Test creating events from empty list.""" events, count = create_events_from_incidents([]) assert count == 0 assert len(events) == 0 def test_create_events_missing_timestamps(self): """Test creating events with missing timestamps.""" incidents = [{"Id": "1"}] # No timestamps _events, count = create_events_from_incidents(incidents) assert count == 0 class TestSweepOverlappingIncidents: """Tests for sweep_overlapping_incidents function.""" def test_sweep_no_overlap(self): """Test sweep with non-overlapping events.""" events = [ (datetime(2022, 1, 1, 10, 0, tzinfo=UTC), 1), (datetime(2022, 1, 1, 10, 5, tzinfo=UTC), -1), (datetime(2022, 1, 1, 11, 0, tzinfo=UTC), 1), (datetime(2022, 1, 1, 11, 5, tzinfo=UTC), -1), ] max_count, _max_time, _min_count, _min_time = sweep_overlapping_incidents(events) assert max_count == 1 def test_sweep_with_overlap(self): """Test sweep with overlapping events.""" events = [ (datetime(2022, 1, 1, 10, 0, tzinfo=UTC), 1), (datetime(2022, 1, 1, 10, 2, tzinfo=UTC), 1), (datetime(2022, 1, 1, 10, 5, tzinfo=UTC), -1), (datetime(2022, 1, 1, 10, 7, tzinfo=UTC), -1), ] max_count, _max_time, _min_count, _min_time = sweep_overlapping_incidents(events) assert max_count == 2 def test_sweep_empty_events(self): """Test sweep with empty events.""" max_count, max_time, _min_count, _min_time = sweep_overlapping_incidents([]) assert max_count == 0 assert max_time is None ================================================ FILE: deployments/LICENSE-3rd-party.txt ================================================ Third-Party Software Licenses ================================================================================ This file contains the licenses and attributions for third-party Docker container images pulled at runtime as part of the deployments (e.g. when running developer profiles via scripts/dev-profile.sh or docker compose). ================================================================================ 1. alpine:3.23.2 Image: alpine:3.23.2 License: BSD-2-Clause (Docker image packaging); image may contain components under other licenses (e.g. musl, BusyBox). Registry: https://hub.docker.com/_/alpine Source: https://github.com/docker-library/official-images/blob/master/library/alpine License URL: https://gitlab.alpinelinux.org/alpine/docker-abuild/-/blob/master/LICENSE.md Full License Text: See: https://gitlab.alpinelinux.org/alpine/docker-abuild/-/blob/master/LICENSE.md Alpine Linux: https://alpinelinux.org/ 2. arizephoenix/phoenix:version-8.12.1 Image: arizephoenix/phoenix:version-8.12.1 License: Elastic License 2.0 (ELv2) Registry: https://hub.docker.com/r/arizephoenix/phoenix Source: https://github.com/Arize-ai/phoenix License URL: https://github.com/Arize-ai/phoenix/blob/main/LICENSE Description: Phoenix observability and evaluation framework (Arize). Full License Text: See: https://github.com/Arize-ai/phoenix/blob/main/LICENSE See also: https://arize.com/docs/phoenix/self-hosting/license 3. confluentinc/cp-kafka:8.1.1 Image: confluentinc/cp-kafka:8.1.1 License: Apache License 2.0; some components under Confluent Community License. See Confluent Platform license documentation. Registry: https://hub.docker.com/r/confluentinc/cp-kafka Source: https://github.com/confluentinc/common (and Confluent Platform) License URL: https://www.confluent.io/confluent-community-license/ Description: Confluent Platform Kafka. Full License Text: See: https://www.confluent.io/confluent-community-license/ See: https://docs.confluent.io/platform/current/installation/license.html 4. docker.elastic.co/elasticsearch/elasticsearch:9.3.0 Image: docker.elastic.co/elasticsearch/elasticsearch:9.3.0 License: Elastic License 2.0 (ELv2) Registry: https://docker.elastic.co Source: https://github.com/elastic/elasticsearch License URL: https://www.elastic.co/licensing/elastic-license Full License Text: See: https://www.elastic.co/licensing/elastic-license 5. docker.elastic.co/kibana/kibana:9.3.0 Image: docker.elastic.co/kibana/kibana:9.3.0 License: Elastic License 2.0 (ELv2) Registry: https://docker.elastic.co Source: https://github.com/elastic/kibana License URL: https://www.elastic.co/licensing/elastic-license Full License Text: See: https://www.elastic.co/licensing/elastic-license 6. docker.elastic.co/logstash/logstash:9.3.0 Image: docker.elastic.co/logstash/logstash:9.3.0 License: Elastic License 2.0 (ELv2) Registry: https://docker.elastic.co Source: https://github.com/elastic/logstash License URL: https://www.elastic.co/licensing/elastic-license Full License Text: See: https://www.elastic.co/licensing/elastic-license 7. docker.io/alpine/curl:8.12.1 Image: docker.io/alpine/curl:8.12.1 License: MIT (image packaging); image contains Alpine Linux (see alpine above) and curl (curl license). Registry: https://hub.docker.com/r/alpine/curl Source: https://gitlab.alpinelinux.org/alpine/docker-abuild License URL: https://curl.se/docs/copyright.html (curl) Full License Text: See: https://gitlab.alpinelinux.org/alpine/docker-abuild/-/blob/master/LICENSE.md curl: https://curl.se/docs/copyright.html 8. postgres:17.6-alpine Image: postgres:17.6-alpine License: PostgreSQL License (BSD-style). Registry: https://hub.docker.com/_/postgres Source: https://github.com/docker-library/postgres License URL: https://www.postgresql.org/about/licence/ Full License Text: See: https://www.postgresql.org/about/licence/ 9. redis:8.2.2-alpine Image: redis:8.2.2-alpine License: Redis 8 is available under a tri-license: Redis Source Available License v2 (RSALv2), SSPL v1, or AGPL v3. See Redis licensing. Registry: https://hub.docker.com/_/redis Source: https://github.com/docker-library/redis License URL: https://redis.io/legal/licenses Full License Text: See: https://redis.io/legal/licenses ================================================================================ Full License Texts / References: Apache License 2.0: See: https://www.apache.org/licenses/LICENSE-2.0 BSD-2-Clause: See: https://opensource.org/licenses/BSD-2-Clause BSD-3-Clause: See: https://opensource.org/licenses/BSD-3-Clause Confluent Community License: See: https://www.confluent.io/confluent-community-license/ curl License: See: https://curl.se/docs/copyright.html Elastic License 2.0: See: https://www.elastic.co/licensing/elastic-license GNU General Public License v2.0: See: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html MIT License: See: https://opensource.org/licenses/MIT PostgreSQL License: See: https://www.postgresql.org/about/licence/ Redis (RSALv2 / SSPL v1 / AGPL v3): See: https://redis.io/legal/licenses ================================================================================ ================================================ FILE: deployments/MANIFEST ================================================ Build-Date: 2026-03-13T06:08:13Z Build-SHA: cdd604d6baf6c445882ce72ecbf5426287a847ff ================================================ FILE: deployments/NOTICE.md ================================================ # VSS v3.1 - Docker Compose ## Third-Party Notices Docker Compose will download and install additional third-party open source software projects. Review the license terms of these open source projects before use. ## ADDITIONAL INFORMATION: ### Elasticsearch, Logstash, Kibana (v9.3.0) These containers include components licensed under the Elastic License v2 (ELv2). ELv2 permits free use, modification, and redistribution, but restricts offering the software as a managed service. Customers should review the ELv2 terms to ensure their intended use complies. ================================================ FILE: deployments/README.md ================================================ ================================================ FILE: deployments/agents/agent_ui/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: vss-ui: image: nvcr.io/nvidia/vss-core/vss-agent-ui:3.1.0 profiles: ["bp_wh_2d","bp_smc_2d","bp_ps_2d","bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] container_name: metropolis-vss-ui ports: - 3000:3000 restart: always depends_on: vss-agent: condition: service_healthy environment: RUN_APP_NAME: nv-metropolis-bp-vss-ui NEXT_PUBLIC_APP_TITLE: '${NEXT_PUBLIC_APP_TITLE:-VSS BLUEPRINT}' NEXT_PUBLIC_APP_SUBTITLE: '${NEXT_PUBLIC_APP_SUBTITLE:-VISION}' # === Chat Tab Configuration === NEXT_PUBLIC_ENABLE_CHAT_TAB: ${NEXT_PUBLIC_ENABLE_CHAT_TAB:-true} NEXT_PUBLIC_WORKFLOW: '${NEXT_PUBLIC_WORKFLOW:-Vision Agent}' NEXT_PUBLIC_WEBSOCKET_CHAT_COMPLETION_URL: ${BREV_WS_AGENT_URL:-ws://${EXTERNAL_IP}:${VSS_AGENT_PORT:-8000}/websocket} NEXT_PUBLIC_HTTP_CHAT_COMPLETION_URL: http://${HOST_IP}:${VSS_AGENT_PORT:-8000}/chat/stream NEXT_PUBLIC_WEB_SOCKET_DEFAULT_ON: ${NEXT_PUBLIC_WEB_SOCKET_DEFAULT_ON:-false} NEXT_PUBLIC_RIGHT_MENU_OPEN: ${NEXT_PUBLIC_RIGHT_MENU_OPEN:-false} NEXT_PUBLIC_ENABLE_INTERMEDIATE_STEPS: ${NEXT_PUBLIC_ENABLE_INTERMEDIATE_STEPS:-true} NEXT_PUBLIC_SHOW_THEME_TOGGLE_BUTTON: ${NEXT_PUBLIC_SHOW_THEME_TOGGLE_BUTTON:-false} NEXT_PUBLIC_DARK_THEME_DEFAULT: ${NEXT_PUBLIC_DARK_THEME_DEFAULT:-true} NEXT_PUBLIC_SIDE_CHATBAR_COLLAPSED: ${NEXT_PUBLIC_SIDE_CHATBAR_COLLAPSED:-true} NEXT_PUBLIC_CHAT_HISTORY_DEFAULT_ON: ${NEXT_PUBLIC_CHAT_HISTORY_DEFAULT_ON:-true} NEXT_PUBLIC_AGENT_API_URL_BASE: ${BREV_API_URL:-http://${EXTERNAL_IP}:${VSS_AGENT_PORT:-8000}/api/v1} NEXT_PUBLIC_CHAT_INPUT_MIC_ENABLED: ${NEXT_PUBLIC_CHAT_INPUT_MIC_ENABLED:-false} NEXT_PUBLIC_CHAT_MESSAGE_EDIT_ENABLED: ${NEXT_PUBLIC_CHAT_MESSAGE_EDIT_ENABLED:-false} NEXT_PUBLIC_CHAT_MESSAGE_SPEAKER_ENABLED: ${NEXT_PUBLIC_CHAT_MESSAGE_SPEAKER_ENABLED:-false} NEXT_PUBLIC_CHAT_MESSAGE_COPY_ENABLED: ${NEXT_PUBLIC_CHAT_MESSAGE_COPY_ENABLED:-false} NEXT_PUBLIC_INTERACTION_MODAL_CANCEL_ENABLED: ${NEXT_PUBLIC_INTERACTION_MODAL_CANCEL_ENABLED:-false} NEXT_PUBLIC_CHAT_UPLOAD_FILE_ENABLE: ${NEXT_PUBLIC_CHAT_UPLOAD_FILE_ENABLE:-true} # Upload file config template JSON NEXT_PUBLIC_CHAT_UPLOAD_FILE_CONFIG_TEMPLATE_JSON: ${NEXT_PUBLIC_CHAT_UPLOAD_FILE_CONFIG_TEMPLATE_JSON:-{}} # Upload file hidden message template NEXT_PUBLIC_CHAT_UPLOAD_FILE_HIDDEN_MESSAGE_TEMPLATE: "Let's show the videos just uploaded {filenames}?" # Upload file metadata enabled NEXT_PUBLIC_CHAT_UPLOAD_FILE_METADATA_ENABLED: false # Custom agent parameters JSON NEXT_PUBLIC_CHAT_API_CUSTOM_AGENT_PARAMS_JSON: ${NEXT_PUBLIC_CHAT_API_CUSTOM_AGENT_PARAMS_JSON:-{}} # === End of Chat Tab Configuration === # === Alerts Tab Configuration === NEXT_PUBLIC_ENABLE_ALERTS_TAB: ${NEXT_PUBLIC_ENABLE_ALERTS_TAB:-true} NEXT_PUBLIC_VST_API_URL: ${BREV_VST_API_URL:-http://${EXTERNAL_IP}:${VST_PORT:-30888}/vst/api} NEXT_PUBLIC_MDX_WEB_API_URL: ${BREV_MDX_URL:-http://${EXTERNAL_IP}:${MDX_PORT:-8081}} NEXT_PUBLIC_ALERTS_TAB_MAX_RESULT_SIZE: 100 NEXT_PUBLIC_ALERTS_TAB_DEFAULT_TIME_WINDOW_IN_MINUTES: 10 NEXT_PUBLIC_ALERTS_TAB_DEFAULT_AUTO_REFRESH_IN_MILLISECONDS: 1000 NEXT_PUBLIC_ALERTS_TAB_VERIFIED_FLAG_DEFAULT: ${NEXT_PUBLIC_ALERTS_TAB_VERIFIED_FLAG_DEFAULT:-false} NEXT_PUBLIC_ALERTS_TAB_ALERT_REPORT_PROMPT_TEMPLATE: "Generate a report for incident {incidentId} with sensor id {sensorId}." # Max search time limit (0 = unlimited, or use: 10m, 2h, 3d, 1w, 2M, 1y) NEXT_PUBLIC_ALERTS_TAB_MAX_SEARCH_TIME_LIMIT: 0 NEXT_PUBLIC_ALERTS_TAB_MEDIA_WITH_OBJECTS_BBOX: ${NEXT_PUBLIC_ALERTS_TAB_MEDIA_WITH_OBJECTS_BBOX:-true} # === End of Alerts Tab Configuration === # === Dashboard Tab Configuration === NEXT_PUBLIC_ENABLE_DASHBOARD_TAB: ${NEXT_PUBLIC_ENABLE_DASHBOARD_TAB:-true} NEXT_PUBLIC_DASHBOARD_TAB_KIBANA_BASE_URL: ${BREV_KIBANA_URL:-http://${EXTERNAL_IP}:5601} # === End of Dashboard Tab Configuration === # === SMC Map Tab Configuration === NEXT_PUBLIC_ENABLE_MAP_TAB: ${NEXT_PUBLIC_ENABLE_MAP_TAB:-false} NEXT_PUBLIC_MAP_URL: ${BREV_MAP_URL:-http://${EXTERNAL_IP}:3002} # === End of SMC Map Tab Configuration === # === Video Management Tab Configuration === NEXT_PUBLIC_ENABLE_VIDEO_MANAGEMENT_TAB: ${NEXT_PUBLIC_ENABLE_VIDEO_MANAGEMENT_TAB:-true} NEXT_PUBLIC_VIDEO_MANAGEMENT_TAB_ADD_RTSP_ENABLE: ${NEXT_PUBLIC_VIDEO_MANAGEMENT_TAB_ADD_RTSP_ENABLE:-true} NEXT_PUBLIC_VIDEO_MANAGEMENT_VIDEO_UPLOAD_ENABLE: ${NEXT_PUBLIC_VIDEO_MANAGEMENT_VIDEO_UPLOAD_ENABLE:-true} # === End of Video Management Tab Configuration === # === Search Tab Configuration === NEXT_PUBLIC_ENABLE_SEARCH_TAB: ${NEXT_PUBLIC_ENABLE_SEARCH_TAB:-false} NEXT_PUBLIC_SEARCH_TAB_MEDIA_WITH_OBJECTS_BBOX: ${NEXT_PUBLIC_SEARCH_TAB_MEDIA_WITH_OBJECTS_BBOX:-true} # --- Search Tab Chat (collapsible sidebar) --- # Default false; set to true to enable Chat sidebar on Search tab NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDEBAR_ENABLE: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDEBAR_ENABLE:-false} # Same semantics as main Chat tab; prefix NEXT_PUBLIC_SEARCH_TAB_CHAT_* (fallback to main NEXT_PUBLIC_* if unset) NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDEBAR_OPEN_DEFAULT: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDEBAR_OPEN_DEFAULT:-false} NEXT_PUBLIC_SEARCH_TAB_CHAT_WORKFLOW: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_WORKFLOW:-Vision Agent} NEXT_PUBLIC_SEARCH_TAB_CHAT_WEBSOCKET_CHAT_COMPLETION_URL: ${BREV_WS_AGENT_URL:-ws://${EXTERNAL_IP}:${VSS_AGENT_PORT:-8000}/websocket} NEXT_PUBLIC_SEARCH_TAB_CHAT_HTTP_CHAT_COMPLETION_URL: http://${HOST_IP}:${VSS_AGENT_PORT:-8000}/chat/stream NEXT_PUBLIC_SEARCH_TAB_CHAT_WEB_SOCKET_DEFAULT_ON: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_WEB_SOCKET_DEFAULT_ON:-false} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_HISTORY_DEFAULT_ON: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_HISTORY_DEFAULT_ON:-true} NEXT_PUBLIC_SEARCH_TAB_CHAT_ENABLE_INTERMEDIATE_STEPS: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_ENABLE_INTERMEDIATE_STEPS:-true} NEXT_PUBLIC_SEARCH_TAB_CHAT_DARK_THEME_DEFAULT: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_DARK_THEME_DEFAULT:-true} NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDE_CHATBAR_COLLAPSED: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDE_CHATBAR_COLLAPSED:-true} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_INPUT_MIC_ENABLED: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_INPUT_MIC_ENABLED:-false} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_MESSAGE_EDIT_ENABLED: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_MESSAGE_EDIT_ENABLED:-false} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_MESSAGE_SPEAKER_ENABLED: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_MESSAGE_SPEAKER_ENABLED:-false} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_MESSAGE_COPY_ENABLED: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_MESSAGE_COPY_ENABLED:-false} NEXT_PUBLIC_SEARCH_TAB_CHAT_INTERACTION_MODAL_CANCEL_ENABLED: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_INTERACTION_MODAL_CANCEL_ENABLED:-false} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_UPLOAD_FILE_ENABLE: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_UPLOAD_FILE_ENABLE:-true} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_UPLOAD_FILE_METADATA_ENABLED: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_UPLOAD_FILE_METADATA_ENABLED:-false} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_UPLOAD_FILE_HIDDEN_MESSAGE_TEMPLATE: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_UPLOAD_FILE_HIDDEN_MESSAGE_TEMPLATE:-} NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_API_CUSTOM_AGENT_PARAMS_JSON: ${NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_API_CUSTOM_AGENT_PARAMS_JSON:-{}} # === End of Search Tab Configuration === ================================================ FILE: deployments/agents/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. include: # - path: ai-agents/ai-agents.yml # - path: ai-agents/nim.yml - path: vss-agent/vss-agent-docker-compose.yml - path: agent_ui/compose.yml ================================================ FILE: deployments/agents/vss-agent/vss-agent-docker-compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Docker Compose for VSS Agent services: vss-va-mcp: image: nvcr.io/nvidia/vss-core/vss-agent:${VSS_AGENT_VERSION} container_name: vss-va-mcp network_mode: host profiles: - bp_wh_2d - bp_smc_2d - bp_ps_2d - bp_developer_alerts_2d_cv - bp_developer_alerts_2d_vlm volumes: - ${MDX_SAMPLE_APPS_DIR}:/vss-agent/deployments:ro environment: - HOST_IP=${HOST_IP} - VST_MCP_URL=${VST_MCP_URL} - VSS_AGENT_HOST=${VSS_AGENT_HOST} - VSS_VA_MCP_PORT=${VSS_VA_MCP_PORT} - VSS_ES_PORT=${VSS_ES_PORT} # HuggingFace token for authenticated downloads (higher rate limits) - HF_TOKEN=${HF_TOKEN:-} command: - mcp - serve - --config_file - ${VSS_VA_MCP_CONFIG_FILE} - --host - ${VSS_AGENT_HOST} - --port - ${VSS_VA_MCP_PORT} healthcheck: test: - CMD - /usr/local/bin/python3 - -c - >- import urllib.request; import sys; sys.exit(0 if urllib.request.urlopen( 'http://${VSS_AGENT_HOST}:${VSS_VA_MCP_PORT}/health', timeout=5).status == 200 else 1) interval: 30s timeout: 10s retries: 3 start_period: 40s restart: unless-stopped vss-agent: # for release, change this to the versioned image from the registry image: nvcr.io/nvidia/vss-core/vss-agent:${VSS_AGENT_VERSION} container_name: vss-agent network_mode: host profiles: - bp_wh_2d - bp_smc_2d - bp_ps_2d - bp_developer_base_2d - bp_developer_search_2d - bp_developer_lvs_2d - bp_developer_alerts_2d_cv - bp_developer_alerts_2d_vlm environment: # URL rewriting configuration EXTERNAL_IP: ${EXTERNAL_IP} INTERNAL_IP: ${HOST_IP} LLM_MODE: ${LLM_MODE} # remote / local / local_shared VLM_MODE: ${VLM_MODE} # remote / local / local_shared LLM_MODEL_TYPE: ${LLM_MODEL_TYPE} # nim / openai VLM_MODEL_TYPE: ${VLM_MODEL_TYPE} # nim / openai # NAT Configuration HOST_IP: ${HOST_IP} VSS_AGENT_VERSION: ${VSS_AGENT_VERSION} VSS_AGENT_CONFIG_FILE: ${VSS_AGENT_CONFIG_FILE} VSS_AGENT_HOST: ${VSS_AGENT_HOST} VSS_AGENT_PORT: ${VSS_AGENT_PORT} LVS_BACKEND_URL: ${LVS_BACKEND_URL} RTVI_EMBED_PORT: ${RTVI_EMBED_PORT} RTVI_CV_PORT: ${RTVI_CV_PORT} VSS_ES_PORT: ${VSS_ES_PORT} # Phoenix Telemetry PHOENIX_ENDPOINT: ${PHOENIX_ENDPOINT} # VST (Video Storage Tool) VST_BASE_URL: ${VST_BASE_URL} VST_INTERNAL_URL: ${VST_INTERNAL_URL} VST_EXTERNAL_URL: ${VST_EXTERNAL_URL} VST_MCP_URL: ${VST_MCP_URL} # MCP Server VIDEO_ANALYSIS_MCP_URL: http://${VSS_AGENT_HOST}:${VSS_VA_MCP_PORT} # LLM Endpoints LLM_NAME: ${LLM_NAME} LLM_BASE_URL: ${LLM_BASE_URL:-http://${HOST_IP}:${LLM_PORT}} VLM_NAME: ${VLM_NAME} VLM_BASE_URL: ${VLM_BASE_URL:-http://${HOST_IP}:${VLM_PORT}} RTVI_VLM_BASE_URL: ${RTVI_VLM_BASE_URL} # NVIDIA API Key (for remote NIM) NVIDIA_API_KEY: ${NVIDIA_API_KEY} # OpenAI API Key (for remote openai LLM/VLM) OPENAI_API_KEY: ${OPENAI_API_KEY:-} # Object Store & Report Generation VSS_AGENT_OBJECT_STORE_TYPE: ${VSS_AGENT_OBJECT_STORE_TYPE} VSS_AGENT_REPORTS_BASE_URL: ${VSS_AGENT_REPORTS_BASE_URL} VSS_AGENT_EXTERNAL_URL: ${VSS_AGENT_EXTERNAL_URL} VSS_AGENT_TEMPLATE_PATH: ${VSS_AGENT_TEMPLATE_PATH} VSS_AGENT_TEMPLATE_NAME: ${VSS_AGENT_TEMPLATE_NAME} # Cosmos Embed & ElasticSearch (for search profile) COSMOS_EMBED_ENDPOINT: ${COSMOS_EMBED_ENDPOINT} ELASTIC_SEARCH_ENDPOINT: ${ELASTIC_SEARCH_ENDPOINT} ELASTIC_SEARCH_INDEX: ${ELASTIC_SEARCH_INDEX} # RTSP Stream Mode ('search' for search profile, rest other) STREAM_MODE: ${STREAM_MODE:-other} # Evaluation, used with nat eval workflow EVAL_LLM_JUDGE_NAME: ${EVAL_LLM_JUDGE_NAME} EVAL_LLM_JUDGE_BASE_URL: ${EVAL_LLM_JUDGE_BASE_URL:-http://${HOST_IP}:${LLM_PORT}} EVAL_DIR: ${EVAL_DIR} DATASET_DIR: ${DATASET_DIR} DATASET_FILE_NAME: ${DATASET_FILE_NAME} EVAL_OUTPUT_DIR: ${EVAL_OUTPUT_DIR} volumes: - ${MDX_SAMPLE_APPS_DIR}:/vss-agent/deployments:ro - agent-eval:/vss-agent/agent_eval:rw command: - serve - --config_file - ${VSS_AGENT_CONFIG_FILE} - --host - ${VSS_AGENT_HOST} - --port - ${VSS_AGENT_PORT} healthcheck: test: - CMD - /usr/local/bin/python3 - -c - >- import urllib.request; import sys; sys.exit(0 if urllib.request.urlopen( 'http://${VSS_AGENT_HOST}:${VSS_AGENT_PORT}/health', timeout=5).status == 200 else 1) interval: 30s timeout: 10s retries: 3 start_period: 240s restart: unless-stopped depends_on: nvidia-nemotron-nano-9b-v2: condition: service_healthy required: false nvidia-nemotron-nano-9b-v2-fp8: condition: service_healthy required: false nemotron-3-nano: condition: service_healthy required: false llama-3.3-nemotron-super-49b-v1.5: condition: service_healthy required: false gpt-oss-20b: condition: service_healthy required: false nvidia-nemotron-nano-9b-v2-shared-gpu: condition: service_healthy required: false nvidia-nemotron-nano-9b-v2-fp8-shared-gpu: condition: service_healthy required: false nemotron-3-nano-shared-gpu: condition: service_healthy required: false llama-3.3-nemotron-super-49b-v1.5-shared-gpu: condition: service_healthy required: false gpt-oss-20b-shared-gpu: condition: service_healthy required: false cosmos-reason1-7b: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false cosmos-reason2-8b: condition: service_healthy required: false cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false rtvi-vlm: condition: service_healthy required: false rtvi-embed: condition: service_healthy required: false vss-va-mcp: condition: service_healthy required: false lvs-server: condition: service_healthy required: false volumes: agent-eval: driver: local driver_opts: type: none o: bind device: $MDX_DATA_DIR/agent_eval ================================================ FILE: deployments/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. include: - path: ./foundational/mdx-foundational.yml - path: ./vst/developer/vst/docker-compose.yaml - path: ./agents/compose.yml - path: ./vlm-as-verifier/compose.yml - path: ./rtvi/compose.yml - path: ./lvs/compose.yml - path: ./nim/compose.yml - path: ./developer-workflow/compose.yml - path: ./proxy/compose.yml ================================================ FILE: deployments/developer-workflow/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. include: - path: ./dev-profile-base/compose.yml - path: ./dev-profile-lvs/compose.yml - path: ./dev-profile-alerts/compose.yml - path: ./dev-profile-search/compose.yml ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/Dockerfiles/EDGE-perception.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # set base image ARG PERCEPTION_IMAGE ARG PERCEPTION_TAG FROM $PERCEPTION_IMAGE:$PERCEPTION_TAG # set the working directory in the container WORKDIR /opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app # copy the dependencies file to the working directory COPY ./deepstream/EDGE-configs/* ./ # copy the start script COPY ./deepstream/init-scripts/ds-start.sh ./ COPY ./deepstream/EDGE-configs/rtdetr-960x544-labels.txt ./ ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/Dockerfiles/kibana-dashboard.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM alpine:3.23.2 # Create a working directory WORKDIR /opt/mdx/ # Copy the init scripts into the working directory COPY ./kibana-dashboard ./ # Install bash and curl commands. RUN apk update && apk add bash RUN apk --no-cache add curl ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/Dockerfiles/perception.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # set base image ARG PERCEPTION_IMAGE ARG PERCEPTION_TAG FROM $PERCEPTION_IMAGE:$PERCEPTION_TAG # set the working directory in the container WORKDIR /opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app # copy the dependencies file to the working directory COPY ./deepstream/configs/* ./ # copy the start script COPY ./deepstream/init-scripts/ds-start.sh ./ COPY ./deepstream/configs/rtdetr-960x544-labels.txt ./ ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: vss-behavior-analytics-alerts: image: nvcr.io/nvidia/vss-core/vss-behavior-analytics:3.1.0 network_mode: "host" profiles: ["bp_developer_alerts_2d_cv"] volumes: - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts/vss-behavior-analytics/configs/vss-behavior-analytics-$STREAM_TYPE-config.json:/resources/vss-behavior-analytics-config.json restart: always container_name: vss-behavior-analytics-alerts command: python3 apps/dev_example/main_dev_example_app.py --config /resources/vss-behavior-analytics-config.json depends_on: broker-health-check: condition: service_completed_successfully nvstreamer-alerts: image: nvcr.io/nvidia/vss-core/vss-vios-nvstreamer:${NVSTREAMER_IMAGE_TAG} user: "0:0" profiles: ["bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] entrypoint: [ "/bin/bash", "-c", "if [ \"$$NVSTREAMER_INSTALL_ADDITIONAL_PACKAGES\" = \"true\" ]; then /home/vst/vst_release/tools/user_additional_install.sh; fi && exec /home/vst/vst_release/launch_vst" ] environment: - NVSTREAMER_INSTALL_ADDITIONAL_PACKAGES=${NVSTREAMER_INSTALL_ADDITIONAL_PACKAGES} - ADAPTOR=streamer - HTTP_PORT=${NVSTREAMER_HTTP_PORT} network_mode: "host" deploy: restart_policy: condition: on-failure max_attempts: 2 container_name: mdx-nvstreamer-alerts volumes: - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts/nvstreamer/configs/vst-config.json:/home/vst/vst_release/configs/vst_config.json - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts/nvstreamer/configs/vst-storage.json:/home/vst/vst_release/configs/vst_storage.json - $MDX_DATA_DIR/videos/dev-profile-alerts:/home/vst/vst_release/streamer_videos - $MDX_DATA_DIR/data_log/nvstreamer/vst_data:/home/vst/vst_release/vst_data - $MDX_SAMPLE_APPS_DIR/vst/scripts/user_additional_install.sh:/home/vst/vst_release/tools/user_additional_install.sh depends_on: broker-health-check: condition: service_completed_successfully perception-sdr-alerts: image: nvcr.io/nvidia/vss-core/sdr:3.1.0 profiles: ["bp_developer_alerts_2d_cv"] network_mode: "host" logging: driver: "json-file" options: max-size: "8192m" max-file: "3" container_name: perception-sdr-alerts volumes: - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts/sdr:/wdm-configs - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts/sdr:/wdm-data - /var/run/docker.sock:/var/run/docker.sock environment: PORT: 4001 OTEL_SDK_DISABLED: true WDM_INITIALIZE_FROM_VST: true WDM_WL_SPEC: /wdm-data/ds-data_wl.yaml WDM_CLUSTER_CONFIG_FILE: /wdm-configs/docker_cluster_config.json WDM_MSG_KEY: vst.event WDM_WL_REDIS_MSG_FIELD: sensor.id WDM_WL_ADD_URL: /api/v1/stream/add WDM_WL_DELETE_URL: /api/v1/stream/remove WDM_WL_HEALTH_CHECK_URL: /api/v1/stream/add VST_STREAMS_ENDPOINT: http://localhost:30888/vst/api/v1/live/streams VST_STATUS_ENDPOINT: http://localhost:30888/vst/api/v1/sensor/status WDM_WL_CHANGE_ID_ADD: camera_streaming WDM_PRELOAD_WORKLOAD: ./tests/event_pre-roll.json WDM_CLEAR_DATA_WL: true WDM_KFK_ENABLE: false WDM_DS_SWAP_ID_NAME: false WDM_VALIDATE_BEFORE_ADD: true WDM_PRELOAD_DELAY_FOR_DS_API: true WDM_WL_THRESHOLD: ${NUM_SENSORS} WDM_CLUSTER_TYPE: docker WDM_POD_WATCH_DOCKER_DELAY: 0.5 WDM_DS_STATUS_CHECK: true WDM_RESTART_DS_ON_ADD_FAIL: false WDM_DISABLE_WERKZEUG_LOGGING: true WDM_WL_OBJECT_NAME: sdr-deepstream WDM_CONSUMER_GRP_ID: sdr-deepstream-cg WDM_CLUSTER_CONTAINER_NAMES: '["perception-alerts"]' deploy: resources: limits: memory: 300M restart_policy: condition: always mode: replicated replicas: 1 entrypoint: [] command: sh -c '/wdm/dist/sdr' perception-alerts: build: context: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts args: PERCEPTION_IMAGE: $PERCEPTION_IMAGE PERCEPTION_TAG: $PERCEPTION_TAG dockerfile: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts/Dockerfiles/${PERCEPTION_DOCKERFILE_PREFIX}perception.Dockerfile network_mode: "host" runtime: nvidia profiles: ["bp_developer_alerts_2d_cv"] container_name: perception-alerts deploy: restart_policy: condition: on-failure max_attempts: 2 resources: reservations: devices: - capabilities: - gpu device_ids: - "${RT_CV_DEVICE_ID:-0}" volumes: - $MDX_DATA_DIR/models/rtdetr-its/model_epoch_035.fp16.onnx:/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/models/rtdetr-its/model_epoch_035.fp16.onnx - $MDX_DATA_DIR/models/gdino/mgdino_mask_head_pruned_dynamic_batch.onnx:/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/models/gdino/mgdino_mask_head_pruned_dynamic_batch.onnx - perception-alerts:/opt/storage environment: MODEL_TYPE: ${MODEL_TYPE} STREAM_TYPE: ${STREAM_TYPE} MODEL_NAME_2D: ${MODEL_NAME_2D} NUM_SENSORS: ${NUM_SENSORS} command: "bash ds-start.sh run_config-api-rtdetr-protobuf.txt" depends_on: kafka-topic-init-container: condition: service_completed_successfully rtvi-vlm: condition: service_healthy required: false kibana-init-container-alerts: build: context: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts dockerfile: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts/Dockerfiles/kibana-dashboard.Dockerfile network_mode: "host" profiles: ["bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] container_name: mdx-kibana-init-alerts command: bash /opt/mdx/init-scripts/kibana-import-dashboard.sh depends_on: kibana: condition: service_healthy vss-video-analytics-api-alerts: image: nvcr.io/nvidia/vss-core/vss-video-analytics-api:3.1.0 network_mode: "host" profiles: ["bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] volumes: - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-alerts/vss-video-analytics-api/configs/video-analytics-api-$STREAM_TYPE-config.json:/opt/mdx/vss-video-analytics-api/configs/vss-video-analytics-api-config.json - vss-video-analytics-api-alerts:/web-api-app/files container_name: vss-video-analytics-api-alerts command: node index.js --config /opt/mdx/vss-video-analytics-api/configs/vss-video-analytics-api-config.json restart: always depends_on: broker-health-check: condition: service_completed_successfully elasticsearch-init-container: condition: service_completed_successfully volumes: mdx-nvstreamer-data: mdx-nvstreamer-videos: vss-video-analytics-api-alerts: driver: local driver_opts: type: none o: bind device: ${MDX_DATA_DIR}/data_log/vss_video_analytics_api perception-alerts: ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/EDGE-configs/cfg_kafka.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [message-broker] partition-key = sensorId ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/EDGE-configs/coco_classmap.txt ================================================ Person bicycle Vehicle Vehicle airplane Vehicle train Vehicle boat traffic light fire hydrant stop sign parking meter bench bird cat dog horse sheep cow elephant bear zebra giraffe backpack umbrella handbag tie suitcase frisbee skis snowboard sports ball kite baseball bat baseball glove skateboard surfboard tennis racket bottle wine glass cup fork knife spoon bowl banana apple sandwich orange broccoli carrot hot dog pizza donut cake chair couch potted plant bed dining table toilet tv laptop mouse remote keyboard cell phone microwave oven toaster sink refrigerator book clock vase scissors teddy bear hair drier toothbrush ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/EDGE-configs/config_triton_nvinferserver_gdino.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. infer_config { unique_id: 1 gpu_ids: [0] max_batch_size: 12 backend { triton { model_name: "ensemble_python_gdino" version: 1 model_repo { root: "/opt/nvidia/deepstream/deepstream/sources/TritonGdino/triton_model_repo/" log_level: 1 strict_model_config: true # Triton runtime would reserve 64MB pinned memory pinned_memory_pool_byte_size: 67108864 # Triton runtim would reserve 64MB CUDA device memory on GPU 0 cuda_device_memory { device: 0, memory_pool_byte_size: 67108864 } } } outputs[ { name: "labels" max_buffer_bytes: 384000 }, { name: "scores" max_buffer_bytes: 768000 }, { name: "boxes" max_buffer_bytes: 3072000 } ] output_mem_type: MEMORY_TYPE_CPU disable_warmup: true } preprocess { tensor_name: "inputs" #network_format: IMAGE_FORMAT_RGB network_format: MEDIA_FORMAT_NONE #tensor_order: TENSOR_ORDER_NHWC tensor_order: TENSOR_ORDER_LINEAR maintain_aspect_ratio: 1 frame_scaling_filter: 1 normalize { scale_factor: 0.017507 channel_offsets: [123.675,116.280,103.53] #scale_factor: 0.01724 } } postprocess { # labelfile_path: "../../triton_tao_model_repo/peoplenet_transformer/labels.txt" other { type_name: "person . ;0.5" } } extra { copy_input_to_host_buffers: false custom_process_funcion: "CreateInferServerCustomProcess" } custom_lib { path: "/opt/nvidia/deepstream/deepstream/sources/TritonGdino/prebuilts/libnvdstriton_custom_impl_gdino.so" } } input_control { process_mode: PROCESS_MODE_FULL_FRAME interval: 0 } output_control { output_tensor_meta: false } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/EDGE-configs/rtdetr-960x544-labels.txt ================================================ background bicycle car person road_sign ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/EDGE-configs/rtdetr-960x544.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [property] gpu-id=0 offsets=0;0;0 net-scale-factor=0.00392156862745098 labelfile-path=/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/rtdetr-960x544-labels.txt model-engine-file=/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/models/rtdetr-its/model_epoch_035.fp16.onnx_b30_gpu0_fp16.engine onnx-file=/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/models/rtdetr-its/model_epoch_035.fp16.onnx batch-size=1 ## 0=FP32, 1=INT8, 2=FP16 mode network-mode=2 network-type=0 num-detected-classes=5 interval=0 gie-unique-id=1 output-blob-names=pred_boxes;pred_logits output-tensor-meta=1 infer-dims=3;544;960 workspace-size=1048576 cluster-mode=4 strongly-typed=1 parse-bbox-func-name=NvDsInferParseCustomDDETRTAO custom-lib-path=/opt/nvidia/deepstream/deepstream/lib/libnvds_infercustomparser_tao.so maintain-aspect-ratio=1 [class-attrs-all] pre-cluster-threshold=0.5 topk=20 ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/EDGE-configs/run_config-api-rtdetr-protobuf.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [application] enable-perf-measurement=1 perf-measurement-interval-sec=5 [tiled-display] enable=3 rows=1 columns=1 width=1920 height=1080 #less black #rows=8 #columns=7 #width=1176 #height=1080 gpu-id=0 # 0 - cuda pinned/host memory # 1 - cuda device memory # 2 - cuda unified memory cuda-memory-type=1 [source-list] num-source-bins=0 #num-source-bins=4 use-nvmultiurisrcbin=1 max-batch-size=30 http-ip=localhost http-port=9010 #sgie batch size is number of sources * fair fraction of number of objects detected per frame per source #the fair fraction of number of object detected is assumed to be 4 sgie-batch-size=30 #Set the below key to keep the application running at all times stream-name-display=1 extract-sei-type5-data=1 sei-uuid=NVDS_CUSTOMMETA [source-attr-all] enable=1 type=3 num-sources=1 gpu-id=0 drop-on-latency=1 cudadec-memtype=0 latency=300 init-rtsp-reconnect-interval-sec=5 rtsp-reconnect-interval-sec=60 [sink0] enable=1 #Type - 1=FakeSink 2=EglSink 3=File type=1 sync=0 source-id=0 gpu-id=0 cuda-memory-type=1 # enable this if sink1 group is disabled for OTEL #nvdslogger=1 [sink1] enable=1 #Type - 1=FakeSink 2=EglSink 3=File 4=UDPSink 5=nvoverlaysink 6=MsgConvBroker type=6 #msg-conv-config=ds-msgconv-config.txt #(0): PAYLOAD_DEEPSTREAM - Deepstream schema payload #(1): PAYLOAD_DEEPSTREAM_MINIMAL - Deepstream schema payload minimal #(256): PAYLOAD_RESERVED - Reserved type #(257): PAYLOAD_CUSTOM - Custom schema payload msg-conv-payload-type=2 #(0): Create payload using NvdsEventMsgMeta #(1): New Api to create payload using NvDsFrameMeta msg-conv-msg2p-new-api=0 #Frame interval at which payload is generated msg-conv-frame-interval=1 msg-broker-proto-lib=/opt/nvidia/deepstream/deepstream/lib/libnvds_kafka_proto.so #Provide your msg-broker-conn-str here #msg-broker-conn-str=qvs-ds-kafka-01;9092;metromind-raw #topic=metromind-raw # msg-broker-conn-str=mdx-kafka-cluster-kafka-brokers;9092;mdx-raw msg-broker-conn-str=localhost;9092;mdx-raw #topic=mdx-raw topic=mdx-raw #Optional: #msg-broker-config=/opt/nvidia/deepstream/deepstream/samples/configs/its_mount/cfg_kafka.txt #new-api=0 #(0) Use message adapter library api's #(1) Use new msgbroker library api's nvdslogger=1 [sink2] enable=0 type=3 #1=mp4 2=mkv container=1 #1=h264 2=h265 3=mpeg4 ## only SW mpeg4 is supported right now. codec=1 sync=1 bitrate=2000000 output-file=/opt/nvidia/deepstream/deepstream/samples/configs/its_mount/rtdetr-perf-tracker.mp4 source-id=0 [sink3] enable=0 #Type - 1=FakeSink 2=EglSink 3=File 4=RTSPStreaming 5=Overlay type=4 #1=h264 2=h265 codec=1 #encoder type 0=Hardware 1=Software enc-type=0 sync=0 bitrate=4000000 #H264 Profile - 0=Baseline 2=Main 4=High #H265 Profile - 0=Main 1=Main10 profile=0 # set below properties in case of RTSPStreaming rtsp-port=8555 udp-port=5401 [osd] enable=0 gpu-id=0 border-width=1 text-size=15 text-color=1;1;1;1; text-bg-color=0.3;0.3;0.3;1 font=Arial show-clock=0 clock-x-offset=800 clock-y-offset=820 clock-text-size=12 clock-color=1;0;0;0 cuda-memory-type=1 [streammux] gpu-id=0 ##Boolean property to inform muxer that sources are live live-source=1 batch-size=30 ##time out in usec, to wait after the first buffer is available ##to push the batch even if the complete batch is not formed batched-push-timeout=33000 ## Set muxer output width and height width=1920 height=1080 ##Enable to maintain aspect ratio wrt source, and allow black borders, works ##along with width, height properties enable-padding=0 nvbuf-memory-type=0 attach-sys-ts-as-ntp=0 drop-pipeline-eos=1 extract-sei-sim-time=1 drop-backward-sei=1 [primary-gie] enable=1 gpu-id=0 batch-size=30 ## 0=FP32, 1=INT8, 2=FP16 mode bbox-border-color0=1;0;0;1 bbox-border-color1=0;1;1;1 bbox-border-color2=0;1;1;1 bbox-border-color3=0;1;0;1 nvbuf-memory-type=0 interval=1 gie-unique-id=1 config-file=rtdetr-960x544.txt [tracker] enable=1 # For NvDCF and NvDeepSORT tracker, tracker-width and tracker-height must be a multiple of 32, respectively tracker-width=960 tracker-height=544 ll-lib-file=/opt/nvidia/deepstream/deepstream/lib/libnvds_nvmultiobjecttracker.so # ll-config-file required to set different tracker types # ll-config-file=config_tracker_IOU.yml # ll-config-file=config_tracker_NvSORT.yml #ll-config-file=/opt/nvidia/deepstream/deepstream/samples/configs/deepstream-app/config_tracker_NvDCF_perf.yml ll-config-file=/opt/nvidia/deepstream/deepstream/samples/configs/deepstream-app/config_tracker_NvDCF_accuracy.yml # ll-config-file=config_tracker_NvDCF_accuracy.yml # ll-config-file=config_tracker_NvDeepSORT.yml gpu-id=0 display-tracking-id=1 [tests] file-loop=1 ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/configs/cfg_kafka.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [message-broker] partition-key = sensorId ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/configs/coco_classmap.txt ================================================ Person bicycle Vehicle Vehicle airplane Vehicle train Vehicle boat traffic light fire hydrant stop sign parking meter bench bird cat dog horse sheep cow elephant bear zebra giraffe backpack umbrella handbag tie suitcase frisbee skis snowboard sports ball kite baseball bat baseball glove skateboard surfboard tennis racket bottle wine glass cup fork knife spoon bowl banana apple sandwich orange broccoli carrot hot dog pizza donut cake chair couch potted plant bed dining table toilet tv laptop mouse remote keyboard cell phone microwave oven toaster sink refrigerator book clock vase scissors teddy bear hair drier toothbrush ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/configs/config_triton_nvinferserver_gdino.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. infer_config { unique_id: 1 gpu_ids: [0] max_batch_size: 12 backend { triton { model_name: "ensemble_python_gdino" version: 1 model_repo { root: "/opt/nvidia/deepstream/deepstream/sources/TritonGdino/triton_model_repo/" log_level: 1 strict_model_config: true # Triton runtime would reserve 64MB pinned memory pinned_memory_pool_byte_size: 67108864 # Triton runtim would reserve 64MB CUDA device memory on GPU 0 cuda_device_memory { device: 0, memory_pool_byte_size: 67108864 } } } outputs[ { name: "labels" max_buffer_bytes: 384000 }, { name: "scores" max_buffer_bytes: 768000 }, { name: "boxes" max_buffer_bytes: 3072000 } ] output_mem_type: MEMORY_TYPE_CPU disable_warmup: true } preprocess { tensor_name: "inputs" #network_format: IMAGE_FORMAT_RGB network_format: MEDIA_FORMAT_NONE #tensor_order: TENSOR_ORDER_NHWC tensor_order: TENSOR_ORDER_LINEAR maintain_aspect_ratio: 1 frame_scaling_filter: 1 normalize { scale_factor: 0.017507 channel_offsets: [123.675,116.280,103.53] #scale_factor: 0.01724 } } postprocess { # labelfile_path: "../../triton_tao_model_repo/peoplenet_transformer/labels.txt" other { type_name: "person . ;0.5" } } extra { copy_input_to_host_buffers: false custom_process_funcion: "CreateInferServerCustomProcess" } custom_lib { path: "/opt/nvidia/deepstream/deepstream/sources/TritonGdino/prebuilts/libnvdstriton_custom_impl_gdino.so" } } input_control { process_mode: PROCESS_MODE_FULL_FRAME interval: 0 } output_control { output_tensor_meta: false } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/configs/rtdetr-960x544-labels.txt ================================================ background bicycle car person road_sign ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/configs/rtdetr-960x544.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [property] gpu-id=0 offsets=0;0;0 net-scale-factor=0.00392156862745098 labelfile-path=/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/rtdetr-960x544-labels.txt model-engine-file=/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/models/rtdetr-its/model_epoch_035.fp16.onnx_b30_gpu0_fp16.engine onnx-file=/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/models/rtdetr-its/model_epoch_035.fp16.onnx batch-size=1 ## 0=FP32, 1=INT8, 2=FP16 mode network-mode=2 network-type=0 num-detected-classes=5 interval=0 gie-unique-id=1 output-blob-names=pred_boxes;pred_logits output-tensor-meta=1 infer-dims=3;544;960 workspace-size=1048576 cluster-mode=4 strongly-typed=1 parse-bbox-func-name=NvDsInferParseCustomDDETRTAO custom-lib-path=/opt/nvidia/deepstream/deepstream/lib/libnvds_infercustomparser_tao.so maintain-aspect-ratio=1 [class-attrs-all] pre-cluster-threshold=0.5 topk=20 ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/configs/run_config-api-rtdetr-protobuf.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [application] enable-perf-measurement=1 perf-measurement-interval-sec=5 [tiled-display] enable=3 rows=1 columns=1 width=1920 height=1080 #less black #rows=8 #columns=7 #width=1176 #height=1080 gpu-id=0 # 0 - cuda pinned/host memory # 1 - cuda device memory # 2 - cuda unified memory cuda-memory-type=1 [source-list] num-source-bins=0 #num-source-bins=4 use-nvmultiurisrcbin=1 max-batch-size=30 http-ip=localhost http-port=9010 #sgie batch size is number of sources * fair fraction of number of objects detected per frame per source #the fair fraction of number of object detected is assumed to be 4 sgie-batch-size=30 #Set the below key to keep the application running at all times stream-name-display=1 extract-sei-type5-data=1 sei-uuid=NVDS_CUSTOMMETA [source-attr-all] enable=1 type=3 num-sources=1 gpu-id=0 cudadec-memtype=0 drop-on-latency=1 latency=300 init-rtsp-reconnect-interval-sec=5 rtsp-reconnect-interval-sec=60 [sink0] enable=1 #Type - 1=FakeSink 2=EglSink 3=File type=1 sync=0 source-id=0 gpu-id=0 cuda-memory-type=1 # enable this if sink1 group is disabled for OTEL #nvdslogger=1 [sink1] enable=1 #Type - 1=FakeSink 2=EglSink 3=File 4=UDPSink 5=nvoverlaysink 6=MsgConvBroker type=6 #msg-conv-config=ds-msgconv-config.txt #(0): PAYLOAD_DEEPSTREAM - Deepstream schema payload #(1): PAYLOAD_DEEPSTREAM_MINIMAL - Deepstream schema payload minimal #(256): PAYLOAD_RESERVED - Reserved type #(257): PAYLOAD_CUSTOM - Custom schema payload msg-conv-payload-type=2 #(0): Create payload using NvdsEventMsgMeta #(1): New Api to create payload using NvDsFrameMeta msg-conv-msg2p-new-api=0 #Frame interval at which payload is generated msg-conv-frame-interval=1 msg-broker-proto-lib=/opt/nvidia/deepstream/deepstream/lib/libnvds_kafka_proto.so #Provide your msg-broker-conn-str here #msg-broker-conn-str=qvs-ds-kafka-01;9092;metromind-raw #topic=metromind-raw # msg-broker-conn-str=mdx-kafka-cluster-kafka-brokers;9092;mdx-raw msg-broker-conn-str=localhost;9092;mdx-raw #topic=mdx-raw topic=mdx-raw #Optional: #msg-broker-config=/opt/nvidia/deepstream/deepstream/samples/configs/its_mount/cfg_kafka.txt #new-api=0 #(0) Use message adapter library api's #(1) Use new msgbroker library api's nvdslogger=1 [sink2] enable=0 type=3 #1=mp4 2=mkv container=1 #1=h264 2=h265 3=mpeg4 ## only SW mpeg4 is supported right now. codec=1 sync=1 bitrate=2000000 output-file=/opt/nvidia/deepstream/deepstream/samples/configs/its_mount/rtdetr-perf-tracker.mp4 source-id=0 [sink3] enable=0 #Type - 1=FakeSink 2=EglSink 3=File 4=RTSPStreaming 5=Overlay type=4 #1=h264 2=h265 codec=1 #encoder type 0=Hardware 1=Software enc-type=0 sync=0 bitrate=4000000 #H264 Profile - 0=Baseline 2=Main 4=High #H265 Profile - 0=Main 1=Main10 profile=0 # set below properties in case of RTSPStreaming rtsp-port=8555 udp-port=5401 [osd] enable=0 gpu-id=0 border-width=1 text-size=15 text-color=1;1;1;1; text-bg-color=0.3;0.3;0.3;1 font=Arial show-clock=0 clock-x-offset=800 clock-y-offset=820 clock-text-size=12 clock-color=1;0;0;0 cuda-memory-type=1 [streammux] gpu-id=0 ##Boolean property to inform muxer that sources are live live-source=1 batch-size=30 ##time out in usec, to wait after the first buffer is available ##to push the batch even if the complete batch is not formed batched-push-timeout=33000 ## Set muxer output width and height width=1920 height=1080 ##Enable to maintain aspect ratio wrt source, and allow black borders, works ##along with width, height properties enable-padding=0 nvbuf-memory-type=0 attach-sys-ts-as-ntp=0 drop-pipeline-eos=1 extract-sei-sim-time=1 drop-backward-sei=1 [primary-gie] enable=1 gpu-id=0 batch-size=30 ## 0=FP32, 1=INT8, 2=FP16 mode bbox-border-color0=1;0;0;1 bbox-border-color1=0;1;1;1 bbox-border-color2=0;1;1;1 bbox-border-color3=0;1;0;1 nvbuf-memory-type=0 interval=0 gie-unique-id=1 config-file=rtdetr-960x544.txt [tracker] enable=1 # For NvDCF and NvDeepSORT tracker, tracker-width and tracker-height must be a multiple of 32, respectively tracker-width=960 tracker-height=544 ll-lib-file=/opt/nvidia/deepstream/deepstream/lib/libnvds_nvmultiobjecttracker.so # ll-config-file required to set different tracker types # ll-config-file=config_tracker_IOU.yml # ll-config-file=config_tracker_NvSORT.yml #ll-config-file=/opt/nvidia/deepstream/deepstream/samples/configs/deepstream-app/config_tracker_NvDCF_perf.yml ll-config-file=/opt/nvidia/deepstream/deepstream/samples/configs/deepstream-app/config_tracker_NvDCF_accuracy.yml # ll-config-file=config_tracker_NvDCF_accuracy.yml # ll-config-file=config_tracker_NvDeepSORT.yml gpu-id=0 display-tracking-id=1 [tests] file-loop=1 ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/deepstream/init-scripts/ds-start.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Set default config file or use the first parameter if provided CONFIG_FILE=${1:-"run_config-api-rtdetr-protobuf700.txt"} if [[ $MODEL_NAME_2D == "GDINO" ]]; then cp /opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/models/gdino/*.onnx /opt/storage/ fi cp /opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/models/rtdetr-its/resnet50_market1501.etlt /opt/nvidia/deepstream/deepstream/samples/models/Tracker/resnet50_market1501.etlt # Set default NUM_SENSORS if not defined in environment NUM_SENSORS=${NUM_SENSORS:-30} echo "##### Using NUM_SENSORS=${NUM_SENSORS} #####" # Modify CONFIG_FILE with NUM_SENSORS values for batch sizes echo "##### Updating batch size configurations in $CONFIG_FILE with NUM_SENSORS=${NUM_SENSORS}... #####" # Update max-batch-size under [source-list] section sed -i "/^\[source-list\]/,/^\[/{s/^max-batch-size=.*/max-batch-size=${NUM_SENSORS}/;}" $CONFIG_FILE # Update batch-size under [streammux] section sed -i "/^\[streammux\]/,/^\[/{s/^batch-size=.*/batch-size=${NUM_SENSORS}/;}" $CONFIG_FILE # Update batch-size under [primary-gie] section sed -i "/^\[primary-gie\]/,/^\[/{s/^batch-size=.*/batch-size=${NUM_SENSORS}/;}" $CONFIG_FILE echo "##### Batch size configurations updated successfully in $CONFIG_FILE... #####" if [[ $MODEL_NAME_2D == "GDINO" ]]; then echo "##### Building engine file for /opt/storage/mgdino_mask_head_pruned_dynamic_batch.onnx ... #####" /usr/src/tensorrt/bin/trtexec --onnx=/opt/storage/mgdino_mask_head_pruned_dynamic_batch.onnx \ --minShapes=inputs:1x3x544x960,input_ids:1x256,attention_mask:1x256,position_ids:1x256,token_type_ids:1x256,text_token_mask:1x256x256 \ --optShapes=inputs:1x3x544x960,input_ids:1x256,attention_mask:1x256,position_ids:1x256,token_type_ids:1x256,text_token_mask:1x256x256 \ --maxShapes=inputs:${NUM_SENSORS}x3x544x960,input_ids:${NUM_SENSORS}x256,attention_mask:${NUM_SENSORS}x256,position_ids:${NUM_SENSORS}x256,token_type_ids:${NUM_SENSORS}x256,text_token_mask:${NUM_SENSORS}x256x256 \ --useCudaGraph \ --fp16 \ --saveEngine=/opt/storage/model_gdino_trt.plan cp /opt/storage/model_gdino_trt.plan /opt/nvidia/deepstream/deepstream/sources/TritonGdino/triton_model_repo/gdino_trt/1/model.plan echo "##### Engine file for /opt/storage/mgdino_mask_head_pruned_dynamic_batch.onnx built successfully... #####" # Modify configuration files for GDINO echo "##### Modifying run_config-api-rtdetr-protobuf700.txt for GDINO configuration... #####" sed -i '/^\[primary-gie\]/,/^\[/{s/config-file=.*/config-file=config_triton_nvinferserver_gdino.txt/;}' $CONFIG_FILE sed -i '/config-file=config_triton_nvinferserver_gdino.txt/a plugin-type=1' $CONFIG_FILE # Update max_batch_size in GDINO config file echo "##### Updating max_batch_size to ${NUM_SENSORS} in config_triton_nvinferserver_gdino.txt... #####" sed -i "s/max_batch_size: [0-9]\+/max_batch_size: ${NUM_SENSORS}/" config_triton_nvinferserver_gdino.txt # Modify max_batch_size to NUM_SENSORS in GDINO Triton config files echo "##### Updating max_batch_size to ${NUM_SENSORS} in GDINO Triton model config files... #####" # Define config files to modify GDINO_CONFIG_FILES=( "/opt/nvidia/deepstream/deepstream/sources/TritonGdino/triton_model_repo/ensemble_python_gdino/config.pbtxt" "/opt/nvidia/deepstream/deepstream/sources/TritonGdino/triton_model_repo/gdino_trt/config.pbtxt" "/opt/nvidia/deepstream/deepstream/sources/TritonGdino/triton_model_repo/gdino_postprocess/config.pbtxt" "/opt/nvidia/deepstream/deepstream/sources/TritonGdino/triton_model_repo/gdino_preprocess/config.pbtxt" ) # Modify each config file for config_file in "${GDINO_CONFIG_FILES[@]}"; do if [[ -f "$config_file" ]]; then echo "Updating max_batch_size in $config_file" # Handle different possible formats of max_batch_size sed -i \ -e "s/^\s*max_batch_size\s*:\s*[0-9]\+\s*$/max_batch_size: ${NUM_SENSORS}/" \ -e "s/^\s*max_batch_size\s*:\s*\"\s*[0-9]\+\s*\"\s*$/max_batch_size: ${NUM_SENSORS}/" \ -e "s/^\s*max_batch_size\s*=\s*[0-9]\+\s*$/max_batch_size = ${NUM_SENSORS}/" \ -e "s/^\s*max_batch_size\s*=\s*\"\s*[0-9]\+\s*\"\s*$/max_batch_size = ${NUM_SENSORS}/" \ "$config_file" else echo "Warning: Config file $config_file not found, skipping..." fi done echo "##### GDINO config files updated successfully... #####" fi # Set -m parameter based on MODEL_NAME_2D if [[ $MODEL_NAME_2D == "GDINO" ]]; then M_PARAM=4 else M_PARAM=7 fi # Check STREAM_TYPE and run appropriate command if [ "$STREAM_TYPE" = "kafka" ]; then echo "Running metropolis_perception_app with kafka configuration..." echo -e "\nds main configs\n" cat $CONFIG_FILE ./metropolis_perception_app -c $CONFIG_FILE -m $M_PARAM -t 0 -l 5 --message-rate 1 --show-sensor-id # elif [ "$STREAM_TYPE" = "redis" ]; then # echo "Running metropolis_perception_app with redis configuration..." # echo -e "\nds main configs\n" # cat ds-main-redis-config.txt # ./metropolis_perception_app -c ds-main-redis-config.txt -m 1 -t 0 -l 5 --message-rate 1 else echo "STREAM_TYPE not set or invalid. Defaulting to kafka configuration..." echo -e "\nds main configs\n" cat $CONFIG_FILE ./metropolis_perception_app -c $CONFIG_FILE -m $M_PARAM -t 0 -l 5 --message-rate 1 --show-sensor-id fi ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/kibana-dashboard/init-scripts/kibana-import-dashboard.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -e # KIBANA CONNECTION VARIABLES KB_CONNECTION_RETRY_ATTEMPTS=0 KB_CONNECTION_MAX_ATTEMPTS=10 KB_URL="http://localhost:5601" # ES CONNECTION VARIABLES ES_CONNECTION_RETRY_ATTEMPTS=0 ES_CONNECTION_MAX_ATTEMPTS=10 ES_URL="http://localhost:9200" ################################# ## function: check_ES_status ################################# check_ES_status(){ echo "Attempting to connect to the Elasticsearch server." # Wait for ES to come up until $(curl --output /dev/null --silent --head --fail -XGET $ES_URL); do if [ ${ES_CONNECTION_RETRY_ATTEMPTS} -eq ${ES_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to ES reached." fi ES_CONNECTION_RETRY_ATTEMPTS=$(($ES_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to ES. Trying to reconnect - (attempt $ES_CONNECTION_RETRY_ATTEMPTS/$ES_CONNECTION_MAX_ATTEMPTS)" sleep 5 done } ################################# ## function: check_kibana_status ################################# check_kibana_status(){ echo "Attempting to connect to the Kibana." # Wait for ES to come up until $(curl --output /dev/null --silent --head --fail -XGET $KB_URL); do if [ ${KB_CONNECTION_RETRY_ATTEMPTS} -eq ${KB_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to Kibana reached." fi KB_CONNECTION_RETRY_ATTEMPTS=$(($KB_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to Kibana. Trying to reconnect - (attempt $KB_CONNECTION_RETRY_ATTEMPTS/$KB_CONNECTION_MAX_ATTEMPTS)." sleep 5 done } ############################ ## function: exit_with_msg ############################ exit_with_msg(){ echo -e "$1 \nExiting Script." exit 1 } ############################## ## function: import_dashboard ############################## import_dashboard(){ echo -e "Importing Dashboards" curl -X POST localhost:5601/api/saved_objects/_import?overwrite=true \ -H "kbn-xsrf: true" \ --form file=@"/opt/mdx/its-kibana-objects.ndjson" || exit_with_msg "Curl command to import kibana dashboard failed with failed with error code $?." } ###################### ## Main ###################### main(){ check_ES_status check_kibana_status # Wait for ES and Kibana initizaliztion to avoid startup raise conditions. sleep 10 import_dashboard } main ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/kibana-dashboard/its-kibana-objects.ndjson ================================================ {"attributes":{"fields":"[{\"name\":\"timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"version\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"version.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"version\"}}},{\"name\":\"Id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"Id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"Id\"}}},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"analyticsModule.info.clusterIndex\",\"type\":\"number\",\"esTypes\":[\"long\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"analyticsModule.info.clusterModel\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.info.clusterModel.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.info.clusterModel\"}}},{\"name\":\"bearing\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"direction\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"direction.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"direction\"}}},{\"name\":\"distance\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"edges\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"edges.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"edges\"}}},{\"name\":\"end\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"event.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"event.type\"}}},{\"name\":\"id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"id\"}}},{\"name\":\"length\",\"type\":\"number\",\"esTypes\":[\"long\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"locations\",\"type\":\"geo_shape\",\"esTypes\":[\"geo_shape\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"object.bbox.bottomrightx\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.bottomrighty\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.topleftx\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.toplefty\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.x\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.y\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.z\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.direction\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.id\"}}},{\"name\":\"object.location.alt\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.location.lat\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.location.lon\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.orientation\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.speed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.vehicle.color\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.color.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.color\"}}},{\"name\":\"object.vehicle.confidence\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.vehicle.license\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.license.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.license\"}}},{\"name\":\"object.vehicle.licenseState\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.licenseState.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.licenseState\"}}},{\"name\":\"object.vehicle.make\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.make.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.make\"}}},{\"name\":\"object.vehicle.model\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.model.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.model\"}}},{\"name\":\"object.vehicle.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.type\"}}},{\"name\":\"place.name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"place.name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"place.name\"}}},{\"name\":\"sensor.description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.description.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.description\"}}},{\"name\":\"sensor.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.id\"}}},{\"name\":\"sensor.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.type\"}}},{\"name\":\"smoothLocations\",\"type\":\"geo_shape\",\"esTypes\":[\"geo_shape\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"speed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"speedOverTime\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"start\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"timeInterval\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"type\"}}},{\"name\":\"videoPath\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"videoPath.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"videoPath\"}}}]","timeFieldName":"timestamp","title":"mdx-behavior-*"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzUsMV0="} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"},"title":"Sensor Selector","uiStateJSON":"{}","version":1,"visState":"{\"type\":\"input_control_vis\",\"aggs\":[],\"params\":{\"controls\":[{\"id\":\"1648246990182\",\"fieldName\":\"sensor.id.keyword\",\"parent\":\"\",\"label\":\"\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"dynamicOptions\":true,\"size\":5,\"order\":\"desc\"},\"indexPatternRefName\":\"control_0_index_pattern\"}],\"updateFiltersOnChange\":false,\"useTimeFilter\":false,\"pinFilters\":false},\"title\":\"Sensor Selector\"}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"control_0_index_pattern","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzYsMV0="} {"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"3f6458bc-732b-44dd-b939-160fd1841969":{"columnOrder":["02090f0a-7691-419c-aa75-de8b87189747","ba9491a7-ccf8-4e42-b990-eabefb957358"],"columns":{"02090f0a-7691-419c-aa75-de8b87189747":{"dataType":"date","isBucketed":true,"label":"timestamp","operationType":"date_histogram","params":{"includeEmptyRows":true,"interval":"auto"},"scale":"interval","sourceField":"timestamp"},"ba9491a7-ccf8-4e42-b990-eabefb957358":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"___records___"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"layers":[{"accessors":["ba9491a7-ccf8-4e42-b990-eabefb957358"],"layerId":"3f6458bc-732b-44dd-b939-160fd1841969","layerType":"data","position":"top","seriesType":"bar_stacked","showGridlines":false,"xAccessor":"02090f0a-7691-419c-aa75-de8b87189747"}],"legend":{"isVisible":true,"legendSize":"auto","position":"right"},"preferredSeriesType":"bar_stacked"}},"title":"Traffic Over Time","visualizationType":"lnsXY"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"indexpattern-datasource-layer-3f6458bc-732b-44dd-b939-160fd1841969","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzcsMV0="} {"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"996d02e5-9798-4f98-9723-7e552b61ee16":{"columnOrder":["3a37530d-7a64-494c-9599-3e93fa8e0d0d","f4f9957f-bed8-4455-8de4-10587adf1b19"],"columns":{"3a37530d-7a64-494c-9599-3e93fa8e0d0d":{"dataType":"date","isBucketed":true,"label":"timestamp","operationType":"date_histogram","params":{"includeEmptyRows":true,"interval":"auto"},"scale":"interval","sourceField":"timestamp"},"f4f9957f-bed8-4455-8de4-10587adf1b19":{"dataType":"number","isBucketed":false,"label":"Average of speed","operationType":"average","scale":"ratio","sourceField":"speed"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"layers":[{"accessors":["f4f9957f-bed8-4455-8de4-10587adf1b19"],"layerId":"996d02e5-9798-4f98-9723-7e552b61ee16","layerType":"data","seriesType":"line","xAccessor":"3a37530d-7a64-494c-9599-3e93fa8e0d0d"}],"legend":{"isVisible":true,"legendSize":"auto","position":"right"},"preferredSeriesType":"line"}},"title":"Average Speed Over Time","visualizationType":"lnsXY"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"indexpattern-datasource-layer-996d02e5-9798-4f98-9723-7e552b61ee16","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzgsMV0="} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"title":"Sensor Table [Shows objects detected for a sensor]]","uiStateJSON":"{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}","version":1,"visState":"{\"type\":\"table\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"sensor.id.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":100,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"},\"schema\":\"bucket\"}],\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"percentageCol\":\"\",\"showToolbar\":true},\"title\":\"Sensor Table [Shows objects detected for a sensor]]\"}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzksMV0="} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"title":"Speed","uiStateJSON":"{}","version":1,"visState":"{\"type\":\"pie\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"range\",\"params\":{\"field\":\"speed\",\"ranges\":[{\"from\":0,\"to\":15},{\"from\":15,\"to\":30},{\"from\":30,\"to\":45},{\"from\":45,\"to\":60},{\"from\":60,\"to\":75},{\"from\":75}]},\"schema\":\"segment\"}],\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":true,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true,\"legendDisplay\":\"show\",\"legendSize\":\"auto\"},\"title\":\"Speed\"}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzEwLDFd"} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"title":"Vehicle Distance (meters)","uiStateJSON":"{}","version":1,"visState":"{\"type\":\"pie\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"range\",\"params\":{\"field\":\"distance\",\"ranges\":[{\"from\":0,\"to\":25},{\"from\":25,\"to\":50},{\"from\":50,\"to\":75},{\"from\":75,\"to\":100},{\"from\":100,\"to\":125},{\"from\":125,\"to\":150},{\"from\":150}]},\"schema\":\"segment\"}],\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":true,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true,\"legendDisplay\":\"show\",\"legendSize\":\"auto\"},\"title\":\"Vehicle Distance (meters)\"}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzExLDFd"} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"title":"Direction","uiStateJSON":"{}","version":1,"visState":"{\"type\":\"pie\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"direction.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":5,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"},\"schema\":\"segment\"}],\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":true,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true,\"legendDisplay\":\"show\",\"legendSize\":\"auto\"},\"title\":\"Direction\"}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzEyLDFd"} {"attributes":{"fields":"[{\"name\":\"timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"version\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"version.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"version\"}}},{\"name\":\"Id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"Id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"Id\"}}},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"analyticsModule.description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.description.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.description\"}}},{\"name\":\"analyticsModule.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.id\"}}},{\"name\":\"analyticsModule.info.clusterIndex\",\"type\":\"number\",\"esTypes\":[\"long\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bearing\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"direction\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"direction.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"direction\"}}},{\"name\":\"distance\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"edges\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"edges.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"edges\"}}},{\"name\":\"end\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"event.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"event.type\"}}},{\"name\":\"id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"id\"}}},{\"name\":\"length\",\"type\":\"number\",\"esTypes\":[\"long\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"locations\",\"type\":\"geo_shape\",\"esTypes\":[\"geo_shape\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"object.bbox.bottomrightx\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.bottomrighty\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.topleftx\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.toplefty\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.x\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.y\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.z\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.direction\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.id\"}}},{\"name\":\"object.location.alt\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.location.lat\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.location.lon\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.orientation\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.speed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.vehicle.color\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.color.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.color\"}}},{\"name\":\"object.vehicle.confidence\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.vehicle.license\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.license.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.license\"}}},{\"name\":\"object.vehicle.licenseState\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.licenseState.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.licenseState\"}}},{\"name\":\"object.vehicle.make\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.make.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.make\"}}},{\"name\":\"object.vehicle.model\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.model.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.model\"}}},{\"name\":\"object.vehicle.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.type\"}}},{\"name\":\"place.name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"place.name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"place.name\"}}},{\"name\":\"sensor.description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.description.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.description\"}}},{\"name\":\"sensor.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.id\"}}},{\"name\":\"sensor.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.type\"}}},{\"name\":\"smoothLocations\",\"type\":\"geo_shape\",\"esTypes\":[\"geo_shape\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"speed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"speedOverTime\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"start\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"timeInterval\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"type\"}}},{\"name\":\"videoPath\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"videoPath.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"videoPath\"}}}]","timeFieldName":"timestamp","title":"mdx-alerts-*"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"2974cbe0-ac89-11ec-9a9e-43a3f680171e","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzEzLDFd"} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"title":"Alerts Count","uiStateJSON":"{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}","version":1,"visState":"{\"type\":\"table\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"sensor.id.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":100,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"},\"schema\":\"bucket\"},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"analyticsModule.id.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":5,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"},\"schema\":\"bucket\"}],\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"percentageCol\":\"\",\"showToolbar\":true},\"title\":\"Alerts Count\"}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"2974cbe0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzE0LDFd"} {"attributes":{"fields":"[{\"name\":\"timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"version\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"version.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"version\"}}},{\"name\":\"Id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"Id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"Id\"}}},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"bearing\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"direction\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"direction.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"direction\"}}},{\"name\":\"distance\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"edges\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"edges.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"edges\"}}},{\"name\":\"end\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"event.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"event.type\"}}},{\"name\":\"id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"id\"}}},{\"name\":\"length\",\"type\":\"number\",\"esTypes\":[\"long\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"locations\",\"type\":\"geo_shape\",\"esTypes\":[\"geo_shape\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"object.bbox.bottomrightx\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.bottomrighty\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.topleftx\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.bbox.toplefty\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.x\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.y\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.coordinate.z\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.direction\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.id\"}}},{\"name\":\"object.location.alt\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.location.lat\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.location.lon\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.orientation\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.speed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.vehicle.color\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.color.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.color\"}}},{\"name\":\"object.vehicle.confidence\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object.vehicle.license\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.license.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.license\"}}},{\"name\":\"object.vehicle.licenseState\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.licenseState.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.licenseState\"}}},{\"name\":\"object.vehicle.make\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.make.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.make\"}}},{\"name\":\"object.vehicle.model\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.model.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.model\"}}},{\"name\":\"object.vehicle.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"object.vehicle.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"object.vehicle.type\"}}},{\"name\":\"place.name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"place.name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"place.name\"}}},{\"name\":\"sensor.description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.description.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.description\"}}},{\"name\":\"sensor.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.id\"}}},{\"name\":\"sensor.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensor.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensor.type\"}}},{\"name\":\"smoothLocations\",\"type\":\"geo_shape\",\"esTypes\":[\"geo_shape\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"speed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"speedOverTime\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"start\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"timeInterval\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"type\"}}},{\"name\":\"videoPath\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"videoPath.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"videoPath\"}}},{\"name\":\"analyticsModule.description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.description.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.description\"}}},{\"name\":\"analyticsModule.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.id\"}}},{\"name\":\"analyticsModule.request_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.info.anomaly_detected\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.info\"}}},{\"name\":\"analyticsModule.info.confidence\",\"type\":\"number\",\"esTypes\":[\"float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.info\"}}},{\"name\":\"analyticsModule.info.scenario_type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.info\"}}},{\"name\":\"analyticsModule.info.prompt_used\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.info\"}}},{\"name\":\"analyticsModule.info.thinking_process\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.info\"}}}]","timeFieldName":"timestamp","title":"mdx-vlm-alerts-*"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"5b936020-84ca-11ef-ad4c-8ff4259112f2","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzE1LDFd"} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"VLM Verified Alerts Count","uiStateJSON":"{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}","version":1,"visState":"{\"title\":\"VLM Verified Alerts Count\",\"type\":\"table\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{\"emptyAsNull\":false},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"sensorId.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":100,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"includeIsRegex\":true,\"excludeIsRegex\":true},\"schema\":\"bucket\"},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"analyticsModule.id.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":5,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"includeIsRegex\":true,\"excludeIsRegex\":true},\"schema\":\"bucket\"}],\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"percentageCol\":\"\",\"showToolbar\":true,\"autoFitRowToContent\":false}}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"fc202740-8686-11ef-b71f-053526f9297c","managed":false,"references":[{"id":"5b936020-84ca-11ef-ad4c-8ff4259112f2","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzE2LDFd"} {"attributes":{"fields":"[{\"name\":\"Id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"Id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"Id\"}}},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"analyticsModule.description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.description.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.description\"}}},{\"name\":\"analyticsModule.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.id\"}}},{\"name\":\"analyticsModule.source\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.source.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.source\"}}},{\"name\":\"analyticsModule.version\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"analyticsModule.version.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"analyticsModule.version\"}}},{\"name\":\"category\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"category.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"category\"}}},{\"name\":\"end\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"frameIds\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"frameIds.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"frameIds\"}}},{\"name\":\"info.collision_objects\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"info.collision_objects.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"info.collision_objects\"}}},{\"name\":\"info.primary_object_id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"info.primary_object_id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"info.primary_object_id\"}}},{\"name\":\"isAnomaly\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"objectIds\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"objectIds.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"objectIds\"}}},{\"name\":\"place.id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"place.id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"place.id\"}}},{\"name\":\"place.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"place.name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"place.name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"place.name\"}}},{\"name\":\"place.type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"place.type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"place.type\"}}},{\"name\":\"sensorId\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensorId.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensorId\"}}},{\"name\":\"timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"type\"}}}]","timeFieldName":"timestamp","title":"mdx-incidents-*"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"c29fb860-2bf3-11f0-8279-4bed76b69e27","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzE3LDFd"} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"title":"Incidents Count","uiStateJSON":"{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}","version":1,"visState":"{\"type\":\"table\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"sensorId.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":5,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"SensorId\"},\"schema\":\"bucket\"},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"category.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":5,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"Type\"},\"schema\":\"bucket\"}],\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"percentageCol\":\"\",\"row\":true,\"showToolbar\":true},\"title\":\"Incidents Count\"}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","managed":false,"references":[{"id":"c29fb860-2bf3-11f0-8279-4bed76b69e27","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzE4LDFd"} {"attributes":{"allowHidden":false,"fieldAttrs":"{}","fieldFormatMap":"{}","fields":"[]","name":"mdx-vlm-incidents-*","runtimeFieldMap":"{}","sourceFilters":"[]","timeFieldName":"timestamp","title":"mdx-vlm-incidents-*"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"c7626038-5331-466e-ae36-6466c1d7cbcf","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"8.0.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzE5LDFd"} {"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"title":"Clusters","uiStateJSON":"{}","version":1,"visState":"{\"type\":\"pie\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"analyticsModule.info.clusterIndex\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":12,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"},\"schema\":\"segment\"}],\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":true,\"values\":true,\"last_level\":true,\"truncate\":100},\"palette\":{\"type\":\"palette\",\"name\":\"kibana_palette\"},\"distinctColors\":true,\"legendDisplay\":\"show\",\"legendSize\":\"auto\"},\"title\":\"Clusters\"}"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","typeMigrationVersion":"8.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzI2LDFd"} {"attributes":{"columns":["_source"],"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"sort":[],"title":"Behavior Rolling Feed","version":1},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"230b1640-ac90-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","typeMigrationVersion":"10.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzIwLDFd"} {"attributes":{"columns":["_source"],"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"sort":[],"title":"Alerts Rolling Feed","version":1},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"2974cbe0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","typeMigrationVersion":"10.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzIxLDFd"} {"attributes":{"columns":["_source"],"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"sort":[],"title":"Incidents Rolling Feed","version":1},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","managed":false,"references":[{"id":"c29fb860-2bf3-11f0-8279-4bed76b69e27","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","typeMigrationVersion":"10.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzIyLDFd"} {"attributes":{"columns":["_source"],"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"sort":[],"title":"VLM Verified Alerts Rolling Feed","version":1},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","managed":false,"references":[{"id":"5b936020-84ca-11ef-ad4c-8ff4259112f2","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","typeMigrationVersion":"10.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzIzLDFd"} {"attributes":{"fields":"[{\"name\":\"timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"version\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"version.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"version\"}}},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"id\",\"type\":\"number\",\"esTypes\":[\"long\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"objects\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"objects.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"objects\"}}},{\"name\":\"sensorId\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"sensorId.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"sensorId\"}}},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"type.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"type\"}}},{\"name\":\"version\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"version.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"version\"}}}]","timeFieldName":"timestamp","title":"mdx-raw-*"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"17e0fac0-ac89-11ec-9a9e-43a3f680171e","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzI0LDFd"} {"attributes":{"columns":["_source"],"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}"},"sort":[],"title":"Raw Data Rolling Feed","version":1},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"17e0fac0-ac89-11ec-9a9e-43a3f680171e","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","typeMigrationVersion":"10.5.0","updated_at":"2025-11-05T07:04:52.524Z","version":"WzI1LDFd"} {"attributes":{"columns":[],"description":"","grid":{},"hideChart":false,"isTextBasedQuery":false,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"sort":[["timestamp","desc"]],"timeRestore":false,"title":"VLM Verified Incident Rolling Feed"},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:07:59.908Z","id":"69163772-6f92-4664-ba64-734b94f94577","managed":false,"references":[{"id":"c7626038-5331-466e-ae36-6466c1d7cbcf","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","typeMigrationVersion":"10.5.0","updated_at":"2025-11-05T07:26:28.602Z","version":"WzQ4LDFd"} {"attributes":{"controlGroupInput":{"chainingSystem":"HIERARCHICAL","controlStyle":"oneLine","ignoreParentSettingsJSON":"{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}","panelsJSON":"{}","showApplySelections":false},"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"language\":\"kuery\",\"query\":\"\"}}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":true,\"syncCursor\":true,\"syncTooltips\":true,\"hidePanelTitles\":false}","panelsJSON":"[{\"type\":\"visualization\",\"panelRefName\":\"panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"458b63b0-ac8a-11ec-9a9e-43a3f680171e\"},\"panelIndex\":\"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32\",\"gridData\":{\"i\":\"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32\",\"y\":0,\"x\":0,\"w\":10,\"h\":12}},{\"type\":\"lens\",\"panelRefName\":\"panel_1ae3de00-55f4-45cc-8062-6894cda7b74d\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"09be8d30-ac8a-11ec-9a9e-43a3f680171e\"},\"panelIndex\":\"1ae3de00-55f4-45cc-8062-6894cda7b74d\",\"gridData\":{\"i\":\"1ae3de00-55f4-45cc-8062-6894cda7b74d\",\"y\":0,\"x\":10,\"w\":38,\"h\":12}},{\"type\":\"lens\",\"panelRefName\":\"panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"a8e38f40-ac8b-11ec-9a9e-43a3f680171e\",\"title\":\"Average Speed Over Time (miles/hour)\"},\"panelIndex\":\"21cc84ff-8459-4795-ba6d-5edeb99d84c0\",\"gridData\":{\"i\":\"21cc84ff-8459-4795-ba6d-5edeb99d84c0\",\"y\":12,\"x\":0,\"w\":48,\"h\":13}},{\"type\":\"visualization\",\"panelRefName\":\"panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0\",\"embeddableConfig\":{\"savedObjectId\":\"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e\",\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"uiState\":{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}},\"panelIndex\":\"be93fb3e-3a0f-4558-90ef-04d52a4adac0\",\"gridData\":{\"i\":\"be93fb3e-3a0f-4558-90ef-04d52a4adac0\",\"y\":25,\"x\":0,\"w\":10,\"h\":15}},{\"type\":\"visualization\",\"panelRefName\":\"panel_05e97caa-d949-4f9e-a5fd-5225427d10df\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"562e0c20-ac8c-11ec-9a9e-43a3f680171e\",\"title\":\"Vehicle Speed (miles/hour)\"},\"panelIndex\":\"05e97caa-d949-4f9e-a5fd-5225427d10df\",\"gridData\":{\"i\":\"05e97caa-d949-4f9e-a5fd-5225427d10df\",\"y\":25,\"x\":10,\"w\":13,\"h\":15}},{\"type\":\"visualization\",\"panelRefName\":\"panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e\"},\"panelIndex\":\"8d4edb92-7a1b-46e4-8415-1b498f61de6e\",\"gridData\":{\"i\":\"8d4edb92-7a1b-46e4-8415-1b498f61de6e\",\"y\":25,\"x\":23,\"w\":13,\"h\":15}},{\"type\":\"visualization\",\"panelRefName\":\"panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"21fde170-ac8f-11ec-9a9e-43a3f680171e\"},\"panelIndex\":\"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe\",\"gridData\":{\"i\":\"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe\",\"y\":25,\"x\":36,\"w\":12,\"h\":15}},{\"type\":\"visualization\",\"panelRefName\":\"panel_8040bdd5-82b9-4863-abcc-7ccc931a860b\",\"embeddableConfig\":{\"savedObjectId\":\"d2c174f0-ac8e-11ec-9a9e-43a3f680171e\",\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"uiState\":{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}},\"panelIndex\":\"8040bdd5-82b9-4863-abcc-7ccc931a860b\",\"gridData\":{\"i\":\"8040bdd5-82b9-4863-abcc-7ccc931a860b\",\"y\":40,\"x\":0,\"w\":23,\"h\":13}},{\"type\":\"visualization\",\"panelRefName\":\"panel_2a81595d-d235-4eb9-a969-fc974e2aff7a\",\"embeddableConfig\":{\"savedObjectId\":\"fc202740-8686-11ef-b71f-053526f9297c\",\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"uiState\":{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}},\"panelIndex\":\"2a81595d-d235-4eb9-a969-fc974e2aff7a\",\"gridData\":{\"i\":\"2a81595d-d235-4eb9-a969-fc974e2aff7a\",\"y\":40,\"x\":23,\"w\":25,\"h\":13}},{\"type\":\"visualization\",\"panelRefName\":\"panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35\",\"embeddableConfig\":{\"savedObjectId\":\"6f4f7be0-2bf4-11f0-8279-4bed76b69e27\",\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"uiState\":{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}},\"panelIndex\":\"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35\",\"gridData\":{\"i\":\"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35\",\"y\":53,\"x\":0,\"w\":23,\"h\":13}},{\"type\":\"visualization\",\"embeddableConfig\":{\"title\":\"VLM Verified Incidents Count\",\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"savedVis\":{\"title\":\"VLM Verified Alerts Count\",\"description\":\"\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\",\"percentageCol\":\"\",\"showToolbar\":true,\"autoFitRowToContent\":false},\"uiState\":{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}},\"data\":{\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{\"emptyAsNull\":false},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"sensorId.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":100,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"includeIsRegex\":true,\"excludeIsRegex\":true},\"schema\":\"bucket\"},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"analyticsModule.id.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":5,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"includeIsRegex\":true,\"excludeIsRegex\":true},\"schema\":\"bucket\"}],\"searchSource\":{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}}}},\"panelIndex\":\"985f352b-5bf8-4e0a-9839-9e87f88eb16d\",\"gridData\":{\"i\":\"985f352b-5bf8-4e0a-9839-9e87f88eb16d\",\"y\":53,\"x\":23,\"w\":25,\"h\":13}},{\"type\":\"visualization\",\"panelRefName\":\"panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e\"},\"panelIndex\":\"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951\",\"gridData\":{\"i\":\"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951\",\"y\":66,\"x\":0,\"w\":48,\"h\":12}},{\"type\":\"search\",\"panelRefName\":\"panel_2abd918e-6885-408b-88b5-db71541e40b1\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"230b1640-ac90-11ec-9a9e-43a3f680171e\"},\"panelIndex\":\"2abd918e-6885-408b-88b5-db71541e40b1\",\"gridData\":{\"i\":\"2abd918e-6885-408b-88b5-db71541e40b1\",\"y\":78,\"x\":0,\"w\":48,\"h\":12}},{\"type\":\"search\",\"panelRefName\":\"panel_90c51282-9a0d-40d9-97de-6a40e2dc858c\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"1b6d0380-ac90-11ec-9a9e-43a3f680171e\"},\"panelIndex\":\"90c51282-9a0d-40d9-97de-6a40e2dc858c\",\"gridData\":{\"i\":\"90c51282-9a0d-40d9-97de-6a40e2dc858c\",\"y\":90,\"x\":0,\"w\":48,\"h\":12}},{\"type\":\"search\",\"panelRefName\":\"panel_1d119524-dc21-4e69-9245-012b1755385a\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"bd6e5020-24b1-11f0-ba00-e552ec9cb550\"},\"panelIndex\":\"1d119524-dc21-4e69-9245-012b1755385a\",\"gridData\":{\"i\":\"1d119524-dc21-4e69-9245-012b1755385a\",\"y\":102,\"x\":0,\"w\":48,\"h\":15}},{\"type\":\"search\",\"panelRefName\":\"panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"a0875a60-84ca-11ef-ad4c-8ff4259112f2\"},\"panelIndex\":\"4f470fbb-2dc1-4010-b439-d0b431fdae4d\",\"gridData\":{\"i\":\"4f470fbb-2dc1-4010-b439-d0b431fdae4d\",\"y\":117,\"x\":0,\"w\":48,\"h\":12}},{\"type\":\"search\",\"panelRefName\":\"panel_6fce5996-724e-4f58-9290-23a89529d936\",\"embeddableConfig\":{\"enhancements\":{},\"savedObjectId\":\"cb201160-ac8f-11ec-9a9e-43a3f680171e\"},\"panelIndex\":\"6fce5996-724e-4f58-9290-23a89529d936\",\"gridData\":{\"i\":\"6fce5996-724e-4f58-9290-23a89529d936\",\"y\":144,\"x\":0,\"w\":48,\"h\":12}},{\"type\":\"search\",\"panelRefName\":\"panel_50035d3e-d4f8-4af3-82a4-1456d07ad7c0\",\"embeddableConfig\":{\"savedObjectId\":\"69163772-6f92-4664-ba64-734b94f94577\"},\"panelIndex\":\"50035d3e-d4f8-4af3-82a4-1456d07ad7c0\",\"gridData\":{\"i\":\"50035d3e-d4f8-4af3-82a4-1456d07ad7c0\",\"y\":129,\"x\":0,\"w\":48,\"h\":15}}]","timeRestore":false,"title":"ITS Dashboard","version":3},"coreMigrationVersion":"8.8.0","created_at":"2025-11-05T07:04:52.524Z","id":"bb5edd60-ac8f-11ec-9a9e-43a3f680171e","managed":false,"references":[{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"458b63b0-ac8a-11ec-9a9e-43a3f680171e","name":"6859694d-fd8d-44ac-a0f5-fe88f8b1cc32:panel_6859694d-fd8d-44ac-a0f5-fe88f8b1cc32","type":"visualization"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"09be8d30-ac8a-11ec-9a9e-43a3f680171e","name":"1ae3de00-55f4-45cc-8062-6894cda7b74d:panel_1ae3de00-55f4-45cc-8062-6894cda7b74d","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"a8e38f40-ac8b-11ec-9a9e-43a3f680171e","name":"21cc84ff-8459-4795-ba6d-5edeb99d84c0:panel_21cc84ff-8459-4795-ba6d-5edeb99d84c0","type":"lens"},{"id":"0927c0e0-ac89-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"0f8eabd0-ac8c-11ec-9a9e-43a3f680171e","name":"be93fb3e-3a0f-4558-90ef-04d52a4adac0:panel_be93fb3e-3a0f-4558-90ef-04d52a4adac0","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"562e0c20-ac8c-11ec-9a9e-43a3f680171e","name":"05e97caa-d949-4f9e-a5fd-5225427d10df:panel_05e97caa-d949-4f9e-a5fd-5225427d10df","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"b3b1b6d0-ac8c-11ec-9a9e-43a3f680171e","name":"8d4edb92-7a1b-46e4-8415-1b498f61de6e:panel_8d4edb92-7a1b-46e4-8415-1b498f61de6e","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"21fde170-ac8f-11ec-9a9e-43a3f680171e","name":"e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe:panel_e5b3ba69-55c6-4956-8ffd-c5c2f15e12fe","type":"visualization"},{"id":"2974cbe0-ac89-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"d2c174f0-ac8e-11ec-9a9e-43a3f680171e","name":"8040bdd5-82b9-4863-abcc-7ccc931a860b:panel_8040bdd5-82b9-4863-abcc-7ccc931a860b","type":"visualization"},{"id":"5b936020-84ca-11ef-ad4c-8ff4259112f2","name":"2a81595d-d235-4eb9-a969-fc974e2aff7a:kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"},{"id":"fc202740-8686-11ef-b71f-053526f9297c","name":"2a81595d-d235-4eb9-a969-fc974e2aff7a:panel_2a81595d-d235-4eb9-a969-fc974e2aff7a","type":"visualization"},{"id":"fc202740-8686-11ef-b71f-053526f9297c","name":"2a81595d-d235-4eb9-a969-fc974e2aff7a:panel_2a81595d-d235-4eb9-a969-fc974e2aff7a","type":"visualization"},{"id":"fc202740-8686-11ef-b71f-053526f9297c","name":"2a81595d-d235-4eb9-a969-fc974e2aff7a:panel_2a81595d-d235-4eb9-a969-fc974e2aff7a","type":"visualization"},{"id":"fc202740-8686-11ef-b71f-053526f9297c","name":"2a81595d-d235-4eb9-a969-fc974e2aff7a:panel_2a81595d-d235-4eb9-a969-fc974e2aff7a","type":"visualization"},{"id":"fc202740-8686-11ef-b71f-053526f9297c","name":"2a81595d-d235-4eb9-a969-fc974e2aff7a:panel_2a81595d-d235-4eb9-a969-fc974e2aff7a","type":"visualization"},{"id":"c29fb860-2bf3-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"6f4f7be0-2bf4-11f0-8279-4bed76b69e27","name":"6359710a-4c6b-4d63-b0c1-ebdb25bfcd35:panel_6359710a-4c6b-4d63-b0c1-ebdb25bfcd35","type":"visualization"},{"id":"c7626038-5331-466e-ae36-6466c1d7cbcf","name":"985f352b-5bf8-4e0a-9839-9e87f88eb16d:kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"5eb73ad0-ac8f-11ec-9a9e-43a3f680171e","name":"4af47d59-fc7d-4ff5-9ee2-0504ff3e9951:panel_4af47d59-fc7d-4ff5-9ee2-0504ff3e9951","type":"visualization"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"230b1640-ac90-11ec-9a9e-43a3f680171e","name":"2abd918e-6885-408b-88b5-db71541e40b1:panel_2abd918e-6885-408b-88b5-db71541e40b1","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"1b6d0380-ac90-11ec-9a9e-43a3f680171e","name":"90c51282-9a0d-40d9-97de-6a40e2dc858c:panel_90c51282-9a0d-40d9-97de-6a40e2dc858c","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"bd6e5020-24b1-11f0-ba00-e552ec9cb550","name":"1d119524-dc21-4e69-9245-012b1755385a:panel_1d119524-dc21-4e69-9245-012b1755385a","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"a0875a60-84ca-11ef-ad4c-8ff4259112f2","name":"4f470fbb-2dc1-4010-b439-d0b431fdae4d:panel_4f470fbb-2dc1-4010-b439-d0b431fdae4d","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"cb201160-ac8f-11ec-9a9e-43a3f680171e","name":"6fce5996-724e-4f58-9290-23a89529d936:panel_6fce5996-724e-4f58-9290-23a89529d936","type":"search"},{"id":"c7626038-5331-466e-ae36-6466c1d7cbcf","name":"50035d3e-d4f8-4af3-82a4-1456d07ad7c0:kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"},{"id":"69163772-6f92-4664-ba64-734b94f94577","name":"50035d3e-d4f8-4af3-82a4-1456d07ad7c0:panel_50035d3e-d4f8-4af3-82a4-1456d07ad7c0","type":"search"}],"type":"dashboard","typeMigrationVersion":"10.3.0","updated_at":"2025-11-05T07:27:19.425Z","version":"WzUxLDFd"} {"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":24,"missingRefCount":0,"missingReferences":[]} ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/nvstreamer/configs/vst-config.json ================================================ { "network": { "http_port":"31000", "server_domain_name":"", "stunurl_list": ["stun.l.google.com:19302","stun1.l.google.com:19302"], "static_turnurl_list": [], "use_coturn_auth_secret": false, "coturn_turnurl_list_with_secret": [], "use_twilio_stun_turn": false, "twilio_account_sid": "", "twilio_auth_token": "", "use_reverse_proxy": false, "reverse_proxy_server_address": "REVERSE_PROXY_SERVER_ADDRESS:100", "ntp_servers": [], "use_sensor_ntp_time": false, "max_webrtc_out_connections": 8, "max_webrtc_in_connections": 8, "webservice_access_control_list":"", "rtsp_server_port": 31554, "rtsp_server_instances_count": 8, "rtsp_server_use_socket_poll": true, "rtsp_preferred_network_iface":"", "rtcp_rtp_port_multiplex": true, "rtsp_in_base_udp_port_num": -1, "rtsp_out_base_udp_port_num": -1, "rtsp_streaming_over_tcp": false, "rtsp_server_reclamation_client_timeout_sec": 10, "rx_socket_buffer_size":1000000, "tx_socket_buffer_size":1000000, "stream_monitor_interval_secs": 2, "rtp_udp_port_range" : "31000-31200", "udp_latency_ms": 200, "udp_drop_on_latency": false, "webrtc_latency_ms": 1000, "enable_frame_drop": true, "webrtc_video_quality_tunning": { "resolution_2160": { "bitrate_start" : 20000, "bitrate_range" : [10000,50000], "qp_range_I" : [0,20], "qp_range_P" : [0,20] }, "resolution_1440": { "bitrate_start" : 10000, "bitrate_range" : [5000,20000], "qp_range_I" : [0,15], "qp_range_P" : [0,10] }, "resolution_1080": { "bitrate_start" : 5000, "bitrate_range" : [2000,10000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] }, "resolution_720": { "bitrate_start" : 2000, "bitrate_range" : [1000,8000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] }, "resolution_480": { "bitrate_start" : 1000, "bitrate_range" : [500,3000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] } }, "webrtc_peer_conn_timeout_sec": 10, "enable_grpc": false, "grpc_server_port": "50051", "webrtc_in_audio_sender_max_bitrate": 128000, "webrtc_in_video_degradation_preference": "resolution", "webrtc_in_video_sender_max_framerate": 30, "remote_vst_address": "", "webrtc_port_range": {"min":30001, "max":30100}, "enable_websocket_pingpong": false, "websocket_keep_alive_ms": 5000 }, "onvif": { "device_discovery_timeout_secs":10, "onvif_request_timeout_secs":10, "device_discovery_freq_secs":5, "device_discovery_interfaces": [], "max_devices_supported": 100, "default_bitrate_kbps": 8000, "default_framerate": 30, "default_resolution": "1920x1080", "default_gov_length": 60 }, "data": { "storage_config_file": "/home/vst/vst_release/configs/vst_storage.json", "storage_threshold_percentage": 95, "storage_monitoring_frequency_secs": 2, "nv_streamer_directory_path": "/home/vst/vst_release/streamer_videos/", "nv_streamer_loop_playback":true, "nv_streamer_seekable":false, "nv_streamer_sync_playback":false, "nv_streamer_sync_file_count":0, "nv_streamer_max_upload_file_size_MB": 10000, "nv_streamer_media_container_supported": ["mp4","mkv"], "nv_streamer_metadata_container_supported": ["json"], "nv_streamer_rtsp_server_output_buffer_size_kb": 1000, "supported_video_codecs": ["h264", "h265"], "supported_audio_codecs": ["pcmu","pcma","mpeg4-generic"], "enable_aging_policy": false, "max_video_download_size_MB":1000, "always_recording": false, "event_recording": false, "event_record_length_secs": 10, "record_buffer_length_secs": 2, "use_software_path": false, "use_webrtc_inbuilt_encoder": "", "webrtc_in_fixed_resolution": "1280x720", "webrtc_in_max_framerate": 30, "webrtc_in_video_bitrate_thresold_percentage": 50, "webrtc_in_passthrough": false, "webrtc_sender_quality": "pass_through", "enable_rtsp_server_sei_metadata": false, "enable_proxy_server_sei_metadata": false, "gpu_indices" : [], "webrtc_out_enable_insert_sps_pps" : true, "webrtc_out_set_iframe_interval" : 30, "webrtc_out_set_idr_interval" : 256, "webrtc_out_min_drc_interval" : 5, "webrtc_out_encode_fallback_option" : "software", "device_name" : "VST", "device_location" : "", "enable_dec_low_latency_mode": true, "enable_avsync_udp_input": true, "use_standalone_udp_input": false, "enable_silent_audio_in_udp_input": false, "enable_udp_input_dump": false, "webrtc_out_default_resolution": "1920x1080", "use_webrtc_hw_dec": true, "recorder_enable_frame_drop": true, "recorder_max_frame_queue_size_bytes": 16000000, "webrtc_out_enc_quality_tuning": "ultra_low_latency", "webrtc_out_enc_preset": "ultra_fast", "enable_drc": true }, "notifications": { "enable_notification": false, "use_message_broker" : "kafka", "message_broker_topic":"vst.event", "redis_server_env_var": "REDIS_SVC_SERVICE_HOST:6379", "kafka_server_address": "localhost:9092" }, "debug": { "enable_perf_logging":true, "enable_qos_monitoring":true, "qos_logfile_path":"./webroot/log/", "qos_data_capture_interval_sec":1, "qos_data_publish_interval_sec":5, "enable_gst_debug_probes":true, "enable_prometheus":false, "prometheus_port": "8080", "enable_highlighting_logs":true, "enable_debug_apis": true, "dump_webrtc_input_stats": false, "enable_frameid_in_webrtc_stream": false, "enable_network_bandwidth_notification" : false, "enable_latency_logging": false }, "overlay": { "video_metadata_server": "localhost:9200/mdx-raw*", "video_metadata_query_batch_size_num_frames": 300, "use_video_metadata_protobuf": false, "enable_gem_drawing": true, "analytic_server_address": "", "overlay_text_font_type": "DejaVuSansMono.ttf" }, "security": { "use_https": false, "use_rtsp_authentication": false, "use_http_digest_authentication": false, "use_multi_user": false, "enable_user_cleanup": false, "session_max_age_sec": 2592000, "multi_user_extra_options": ["Secure", "SameSite=none"], "nv_org_id": "", "nv_ngc_key": "" }, "observability": { "enable_telemetry": false, "otlp_endpoint": "http://localhost:4318/v1/traces" } } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/nvstreamer/configs/vst-storage.json ================================================ { "data_path": "./vst_data/", "video_path": "./vst_video/", "total_video_storage_size_MB": 100000 } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/sdr/docker_cluster_config.json ================================================ { "mdx-ds-01": { "provisioning_address": "localhost:9010", "process_type": "docker" } } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vlm-as-verifier/configs/EDGE-LOCAL-VLM-config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================ # SIMPLIFIED ALERT AGENT CONFIGURATION # ============================================================================ # This configuration supports the simplified alert processing flow: # JSON Input → Entity Building → VSS Video Analysis → Enhanced JSON Output # Base directory for media used during alert review (optional) # If set, components can resolve relative media paths against this base dir ALERT_REVIEW_MEDIA_BASE_DIR: "" # VST Configuration vst_config: recording_check_max_attempts: 3 # Base URL for general VST APIs already used elsewhere in the codebase base_url: "http://localhost:30888" sensor_list_endpoint: "/vst/api/v1/sensor/streams" add_overlay: false segment_anchor: "end" # enable end-anchored window segment_duration_seconds: 10 # M (already used today) # Storage service configuration (separate base URL and endpoint similar to vss_agent) storage: base_url: "http://localhost:30888" # Endpoint to fetch media file path by VST id media_file_path_by_id_endpoint: "/api/v1/storage/file/path" # General Kafka Configuration - Message Processing kafka: bootstrap_servers: "localhost:9092" group_id: 'kafka-incidents-dumper' # Output topic for incidents max_poll_records: 10 # Process one record at a time (immediate processing) auto_offset_reset: 'latest' enable_auto_commit: false max_poll_interval_ms: 600000 # Reduced to 5 minutes session_timeout_ms: 45000 heartbeat_interval_ms: 15000 poll_timeout: 5 # Kafka consumer poll wait timeout in milliseconds message_type: "Incident" # Protobuf message type: "Behavior" or "Incident" enhanced_anomaly_topic: "alert-bridge-enhanced-alerts" # Not currently used will be removed in the next version incidents_topic: "alert-bridge-incidents" # Not currently used will be removed in the next version #VLM Configuration vlm: base_url: ${VLM_BASE_URL}/v1 model: "${VLM_NAME}" max_tokens: 4096 # Beware that these parameters need to be set in accordance with the VLM max context window min_pixels: 1568 max_pixels: 8388608 enable_sampling: true sampling_fps: 4 request_timeout: 60 use_vlm_media_defaults: true # Event Bridge Configuration - Choose between kafka and redisStream event_bridge: sourceType: "kafka" # redisStream or "kafka" sinkType: "kafka" # redisStream or "kafka" kafka_source: group_id: 'alert-bridge-vlm-group' topics: incident: 'mdx-incidents' # Redis Streams Configuration redis_source: host: "localhost" port: 6379 db: 0 dedup_ttl_seconds: 5 protect_confirmed_verdicts: enabled: true ttl_seconds: 600 # End time delta filter: blocks incidents unless end time changed significantly end_time_delta_filter: enabled: true threshold_seconds: 3 ttl_seconds: 3600 # Categories listed here will retain the incident end timestamp when building the dedup key. # Any category not listed will omit the end timestamp for deduplication purposes. end_time_in_dedup_key_categories: [] streams: anomaly_stream: "alert-bridge-input-stream" heartbeat_stream: "alert-bridge-heartbeats-stream" consumer_group: "vlm_agents_group" consumer_config: block_time: 10 count: 1 batch_size: 1 redis_sink: host: "localhost" port: 6379 db: 0 streams: enhanced_anomaly_stream: "alert-bridge-enhanced-stream" incidents_stream: "alert-bridge-incidents-stream" prompt: prefer_payload_prompt: false override_prompts_on_start: true # Alert Type Configuration alert_type_config_file: "alert_type_config.json" # Alert Agent Configuration - Processing Settings alert_agent: num_workers: 10 # Number of worker threads max_allowed_stream_size: 2 # Maximum stream size in minutes default_stream_interval: 1 # Default stream interval in minutes vst_pass_through_mode: false # Use local media files instead of VST stream lookup include_latency_info: false elastic: enabled: true hosts: - http://localhost:9200 vlm_enhanced_sink: incident: type: "elastic" elastic: index: "mdx-vlm-incidents" alert: type: "elastic" elastic: index: "mdx-vlm-alerts" # vlm_enhanced_sink: # incident: # type: "kafka" # kafka: # topic: "mdx-vlm-incidents" # message_type: "incident" # key_field: "id" # alert: # type: "kafka" # kafka: # topic: "mdx-vlm-alerts" # message_type: "alert" # key_field: "id" # vlm_enhanced_incident_sink: # type: "kafka" # kafka: # topic: "mdx-vlm-incidents" # message_type: "incident" # key_field: "id" logging: level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL (applies to all components) format: "%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s" third_party_level: "WARNING" # Level for urllib3/httpcore/httpx/elasticsearch, etc. ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vlm-as-verifier/configs/alert_type_config.json ================================================ { "version": "1.0", "alerts": [ { "alert_type": "FOV Count Violation", "output_category": "Ladder PPE Violation", "prompts": { "system": "You are a helpful assistant.", "user": "Is anyone on the ladder without a hardhat and safety vest? \nAnswer yes or no." } } ] } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vlm-as-verifier/configs/config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================ # SIMPLIFIED ALERT AGENT CONFIGURATION # ============================================================================ # This configuration supports the simplified alert processing flow: # JSON Input → Entity Building → VSS Video Analysis → Enhanced JSON Output # Base directory for media used during alert review (optional) # If set, components can resolve relative media paths against this base dir ALERT_REVIEW_MEDIA_BASE_DIR: "" # VST Configuration vst_config: recording_check_max_attempts: 3 # Base URL for general VST APIs already used elsewhere in the codebase base_url: "http://localhost:30888" sensor_list_endpoint: "/vst/api/v1/sensor/streams" add_overlay: false segment_anchor: "end" # enable end-anchored window segment_duration_seconds: 10 # M (already used today) # Storage service configuration (separate base URL and endpoint similar to vss_agent) storage: base_url: "http://localhost:30888" # Endpoint to fetch media file path by VST id media_file_path_by_id_endpoint: "/api/v1/storage/file/path" # General Kafka Configuration - Message Processing kafka: bootstrap_servers: "localhost:9092" group_id: 'kafka-incidents-dumper' # Output topic for incidents max_poll_records: 10 # Process one record at a time (immediate processing) auto_offset_reset: 'latest' enable_auto_commit: false max_poll_interval_ms: 600000 # Reduced to 5 minutes session_timeout_ms: 45000 heartbeat_interval_ms: 15000 poll_timeout: 5 # Kafka consumer poll wait timeout in milliseconds message_type: "Incident" # Protobuf message type: "Behavior" or "Incident" enhanced_anomaly_topic: "alert-bridge-enhanced-alerts" # Not currently used will be removed in the next version incidents_topic: "alert-bridge-incidents" # Not currently used will be removed in the next version #VLM Configuration vlm: base_url: ${VLM_BASE_URL}/v1 model: "${VLM_NAME}" max_tokens: 4096 # Beware that these parameters need to be set in accordance with the VLM max context window min_pixels: 1568 max_pixels: 3960000 enable_sampling: true sampling_fps: 4 # Event Bridge Configuration - Choose between kafka and redisStream event_bridge: sourceType: "kafka" # redisStream or "kafka" sinkType: "kafka" # redisStream or "kafka" kafka_source: group_id: 'alert-bridge-vlm-group' topics: incident: 'mdx-incidents' # Redis Streams Configuration redis_source: host: "localhost" port: 6379 db: 0 dedup_ttl_seconds: 5 protect_confirmed_verdicts: enabled: true ttl_seconds: 600 # End time delta filter: blocks incidents unless end time changed significantly end_time_delta_filter: enabled: true threshold_seconds: 3 ttl_seconds: 3600 # Categories listed here will retain the incident end timestamp when building the dedup key. # Any category not listed will omit the end timestamp for deduplication purposes. end_time_in_dedup_key_categories: [] streams: anomaly_stream: "alert-bridge-input-stream" heartbeat_stream: "alert-bridge-heartbeats-stream" consumer_group: "vlm_agents_group" consumer_config: block_time: 10 count: 1 batch_size: 1 redis_sink: host: "localhost" port: 6379 db: 0 streams: enhanced_anomaly_stream: "alert-bridge-enhanced-stream" incidents_stream: "alert-bridge-incidents-stream" prompt: prefer_payload_prompt: false override_prompts_on_start: true # Alert Type Configuration alert_type_config_file: "alert_type_config.json" # Alert Agent Configuration - Processing Settings alert_agent: num_workers: 10 # Number of worker threads max_allowed_stream_size: 2 # Maximum stream size in minutes default_stream_interval: 1 # Default stream interval in minutes vst_pass_through_mode: false # Use local media files instead of VST stream lookup include_latency_info: false elastic: enabled: true hosts: - http://localhost:9200 vlm_enhanced_sink: incident: type: "elastic" elastic: index: "mdx-vlm-incidents" alert: type: "elastic" elastic: index: "mdx-vlm-alerts" # vlm_enhanced_sink: # incident: # type: "kafka" # kafka: # topic: "mdx-vlm-incidents" # message_type: "incident" # key_field: "id" # alert: # type: "kafka" # kafka: # topic: "mdx-vlm-alerts" # message_type: "alert" # key_field: "id" # vlm_enhanced_incident_sink: # type: "kafka" # kafka: # topic: "mdx-vlm-incidents" # message_type: "incident" # key_field: "id" logging: level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL (applies to all components) format: "%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s" third_party_level: "WARNING" # Level for urllib3/httpcore/httpx/elasticsearch, etc. ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vss-agent/configs/config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. general: use_uvloop: true front_end: _type: fastapi runner_class: vss_agents.api.custom_fastapi_worker.CustomFastApiFrontEndWorker object_store: ${VSS_AGENT_OBJECT_STORE_TYPE} # Set to local_object_store or remote_object_store # Configuration for streaming video ingest endpoint streaming_ingest: vst_internal_url: ${VST_INTERNAL_URL} stream_mode: ${STREAM_MODE} # 'search' for search profile, 'other' for VST only endpoints: - path: /api/v1/videos method: POST description: Generate VST upload URL function_name: video_upload_url cors: allow_origins: ['*'] allow_methods: ['*'] allow_headers: ['*'] allow_credentials: false telemetry: tracing: phoenix: _type: phoenix endpoint: ${PHOENIX_ENDPOINT}/v1/traces project: DEV-ALERTS-vss-agent-${VSS_AGENT_VERSION} object_stores: local_object_store: _type: in_memory function_groups: video_analytics_mcp: _type: mcp_client server: transport: streamable-http url: ${VIDEO_ANALYSIS_MCP_URL}/mcp include: - video_analytics__get_incidents - video_analytics__get_incident - video_analytics__get_sensor_ids functions: video_upload_url: _type: video_upload_url vst_external_url: ${VST_EXTERNAL_URL} agent_base_url: ${VSS_AGENT_EXTERNAL_URL} video_understanding: _type: video_understanding vlm_name: ${VLM_MODEL_TYPE:-nim}_vlm max_frames: 30 min_pixels: 3136 max_pixels: 12845056 reasoning: true video_url_tool: vst_video_clip vlm_mode: ${VLM_MODE} internal_ip: ${HOST_IP} external_ip: ${EXTERNAL_IP} vst_internal_url: ${VST_INTERNAL_URL} # Video understanding for incidents (uses ISO timestamps) video_understanding_iso: _type: video_understanding vlm_name: ${VLM_MODEL_TYPE:-nim}_vlm max_frames: 30 min_pixels: 3136 max_pixels: 12845056 reasoning: true video_url_tool: vst_video_url use_base64: true vlm_mode: ${VLM_MODE} internal_ip: ${HOST_IP} external_ip: ${EXTERNAL_IP} vst_internal_url: ${VST_INTERNAL_URL} vst_video_clip: _type: vst.video_clip vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} vst_snapshot: _type: vst.snapshot vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} # ISO timestamp versions for incident reports. # 'iso' because incident reports reference events by absolute wall-clock time from RTSP streams. # Must match time_format in the video_understanding tool that calls these. vst_video_url: _type: vst.video_clip vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} overlay_config: true time_format: iso vst_picture_url: _type: vst.snapshot vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} overlay_config: true time_format: iso get_sensor_names: _type: vst.sensor_list vst_internal_url: ${VST_INTERNAL_URL} video_report_gen: _type: video_report_gen object_store: ${VSS_AGENT_OBJECT_STORE_TYPE} base_url: ${VSS_AGENT_REPORTS_BASE_URL} vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} video_understanding_tool: video_understanding_iso video_url_tool: vst_video_url picture_url_tool: vst_snapshot vlm_prompts: - >- Provide an overview of what is happening in the video, including key events, activities, people, vehicles, and their actions. report_agent: _type: report_agent log_level: INFO # Video Report mode for uploaded videos video_report_tool: video_report_gen # Template Report Gen for incident-based reports (uses ISO timestamps) template_report_gen: _type: template_report_gen object_store: ${VSS_AGENT_OBJECT_STORE_TYPE} base_url: ${VSS_AGENT_REPORTS_BASE_URL} llm_name: ${LLM_MODEL_TYPE:-nim}_llm video_understanding_tool: video_understanding_iso picture_url_tool: vst_picture_url video_url_tool: vst_video_url vlm_prompts: - >- Describe the incident in details including the type of event, objects involved, contributing factors, and any responses observed. - >- Describe environmental conditions in the incident. Include lighting, weather, and any visible hazards. # yamllint disable rule:line-length report_prompt: | Instructions: 1. Extract incident details from tool results (incident ID, sensor ID, timestamp, etc.) 2. Extract video analysis details from video_understanding tool results 3. Generate a structured incident report with sections: Overview, Details, Environmental Conditions, Recommendations 4. Use "Unknown" or "N/A" for fields where information is not available Return only the formatted markdown report. # Incident Report Agent - for incident-based reports from VA-MCP incident_report_agent: _type: report_agent log_level: INFO get_incidents_tool: video_analytics_mcp.video_analytics.get_incidents get_incident_tool: video_analytics_mcp.video_analytics.get_incident template_report_tool: template_report_gen # RTVI-VLM Real-time Stream Alert Tool rtvi_vlm_alert: _type: rtvi_vlm_alert rtvi_vlm_base_url: ${RTVI_VLM_BASE_URL} vst_internal_url: ${VST_INTERNAL_URL} va_get_incidents_tool: video_analytics_mcp.video_analytics.get_incidents default_model: ${VLM_NAME} default_chunk_duration: 2 default_fps: 1 timeout: 60 # Prompt generator for RTVI-VLM alerts (LLM-based) rtvi_prompt_gen: _type: prompt_gen llm_name: prompt_gen_llm # yamllint disable rule:line-length prompt: | You are a prompt generator for video monitoring alerts. Please use the exact prompt if they are provided as examples below. system_prompt should always be "You are a helpful assistant.". User wants to monitor: {user_query} Intent: {user_intent} Generate a Yes/No detection question and a system role. Example for "warehouse anomalies": prompt: Detect for a box being dropped. Answer in Yes or No system_prompt: You are a helpful assistant. Now generate for the user request above. Output format: prompt: [your detection question] system_prompt: You are a helpful assistant. llms: # --- LLM profiles (selected by LLM_MODEL_TYPE) --- nim_llm: _type: nim model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 openai_llm: _type: openai model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 # --- VLM profiles (selected by VLM_MODEL_TYPE) --- nim_vlm: _type: nim model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 1024 openai_vlm: _type: openai model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 vllm_vlm: _type: openai model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 # --- Others --- prompt_gen_llm: _type: ${LLM_MODEL_TYPE:-nim} model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 256 temperature: 0.0 workflow: _type: top_agent llm_name: ${LLM_MODEL_TYPE:-nim}_llm log_level: INFO max_iterations: 15 tool_names: - video_understanding - video_understanding_iso - vst_video_clip - vst_snapshot - get_sensor_names - rtvi_vlm_alert - rtvi_prompt_gen subagent_names: - report_agent # yamllint disable rule:line-length prompt: | You are a routing agent for a video surveillance reporting system with tool calling capabilities. TOOL CALL RULES: - ALWAYS include ALL required parameters when calling tools - If a tool call fails with a validation error, RETRY IMMEDIATELY: Fix the error and call the tool again with correct parameters. PRIORITY ACTIONS: 1. If user asks about available sensors → DIRECTLY CALL get_sensor_names tool 2. For returning a video clip from a sensor -> DIRECTLY CALL vst_video_clip tool 3. For taking a snapshot/picture from a sensor -> DIRECTLY CALL vst_snapshot tool 4. For UPLOADED VIDEO reports (e.g., warehouse_01.mp4) → route to report_agent 5. For INCIDENT reports/analysis → DO NOT use report_agent. Use WORKFLOW 5 below (get_incidents + video_understanding) 6. For REAL-TIME VLM ALERTS (start/stop monitoring) → DIRECTLY CALL rtvi_vlm_alert with action='start' or 'stop' 7. For QUERYING/LISTING INCIDENTS → DIRECTLY CALL rtvi_vlm_alert with action='get_incidents' WORKFLOW 1: List Available Videos User: "What videos are available?" / "Show me all uploaded videos" Action: Call get_sensor_names tool Response: Display formatted list of available video sensors WORKFLOW 2: Generate Video Analysis Report User: "Generate a report for warehouse_01.mp4" / "Analyze the safety in this video" Action: Call report_agent with sensor_id and user_query Response: Report agent will: - Analyze the full video using VLM - Generate structured markdown report with analysis - Return report download URLs and media URLs (snapshot + video playback) Note: report_agent handles ALL video analysis internally - do NOT call video_understanding directly WORKFLOW 3: Get Video Clip or Snapshot User: "Show me a snapshot from warehouse_01.mp4" / "Get the video clip for sensor X" Action: - For snapshot → Call vst_snapshot tool - For video clip → Call vst_video_clip tool. Response: Display the media URL(s) directly to user WORKFLOW 4: Query RTVI-VLM Incidents (List Only) User: "Show me collision incidents from Montague-2-HO-6" / "List alerts for sensor X" Action: Call rtvi_vlm_alert with action="get_incidents" Example: {{"action": "get_incidents", "sensor_name": "Montague-2-HO-6", "max_count": 10}} Optional parameters for filtering: - start_time, end_time: ISO 8601 format (e.g., "2026-01-06T00:00:00.000Z") - incident_type: filter by type (e.g., "collision") - max_count: number of incidents to return (default 10) WORKFLOW 5: Generate Incident Report (Detailed Analysis) User: "Generate a report for the last incident from Montague-2-HO-6" ⚠️ IMPORTANT: DO NOT use report_agent for incidents! report_agent is ONLY for uploaded video files. For incident analysis, use video_understanding DIRECTLY with the time range. This requires TWO STEPS: Step 1 - Get incident details: Call rtvi_vlm_alert with action="get_incidents" and max_count=1 Tool call: rtvi_vlm_alert Args: {{"action": "get_incidents", "sensor_name": "Montague-2-HO-6", "max_count": 1}} This returns the incident with timestamp (e.g., "2026-01-07T02:15:45.674Z") Step 2 - Analyze incident video using video_understanding_iso (NOT report_agent): Call video_understanding_iso (accepts ISO timestamps) with the SENSOR NAME and time range ±30 seconds Tool call: video_understanding_iso Args: {{ "sensor_id": "Montague-2-HO-6", "user_query": "Describe this incident in detail. What happened? What objects/vehicles are involved? What are the environmental conditions?", "start_timestamp": "2026-01-07T02:15:15.674Z", "end_timestamp": "2026-01-07T02:16:15.674Z" }} Final Response: Combine the incident metadata from Step 1 with the VLM analysis from Step 2: - Incident ID, timestamp, location from Step 1 - Detailed description and analysis from Step 2 NOTE: If Step 2 fails (video not available for replay), provide the incident metadata from Step 1: - Report the incident details: timestamp, location, detection prompt, VLM response - Explain that video replay is not available for this live stream === REAL-TIME VLM ALERTS (RTVI-VLM) === Use rtvi_vlm_alert tool to start or stop real-time VLM alert monitoring. 1. START ALERT WITH CUSTOM MONITORING (when user specifies WHAT to monitor): IMMEDIATELY call rtvi_prompt_gen FIRST, then rtvi_vlm_alert: Step 1 - CALL NOW: rtvi_prompt_gen {{ "user_query": "", "user_intent": "real-time monitoring" }} Step 2 - CALL AFTER Step 1: rtvi_vlm_alert {{ "action": "start", "sensor_name": "", "prompt": "", "system_prompt": "" }} Example: "Start alert for vehicle collisions on Camera_02" → Step 1: rtvi_prompt_gen(user_query="vehicle collisions", user_intent="real-time monitoring") → Step 2: rtvi_vlm_alert(action="start", sensor_name="Camera_02", prompt=, system_prompt=) 2. START ALERT (generic, no specific monitoring target): User: "Start real-time alert for sensor warehouse_01.mp4" Tool call: {{ "action": "start", "sensor_name": "warehouse_01.mp4" }} 3. STOP ALERT: User: "Stop real-time alert for sensor warehouse_01.mp4" Tool call: {{ "action": "stop", "sensor_name": "warehouse_01.mp4" }} === TOOL USAGE GUIDELINES === 1. get_sensor_names: - Shows all available uploaded videos - No parameters required - Use when: User asks about available videos/sensors 2. vst_snapshot: - Gets snapshot image from video - Required: sensor_id - Optional: start_time (defaults to current time in ISO 8601 UTC format) - Returns: Image URL - display as: ![Snapshot](image_url) - Use when: User asks for "snapshot", "picture", "image", or "screenshot" 3. vst_video_clip: - Gets playback URL for video clip - Required: sensor_id - Optional: start_time, end_time (for time range) - Returns: Video playback URL - Use when: User asks for "video clip", "playback", or "watch video" 4. video_understanding / video_understanding_iso: - video_understanding: For uploaded videos (uses float offsets) - video_understanding_iso: For INCIDENT ANALYSIS (uses ISO timestamps) - Required: sensor_id (sensor NAME, not UUID), user_query - Optional: start_timestamp, end_timestamp (ISO 8601 format) - For incidents: Use video_understanding_iso with timestamps from rtvi_vlm_alert ±30s 5. rtvi_vlm_alert: - Manage real-time VLM alerts and query incidents - Actions: * action="start": Start monitoring a sensor (requires sensor_name, optional prompt/system_prompt) * action="stop": Stop monitoring a sensor (requires sensor_name) * action="get_incidents": Query detected incidents (requires sensor_name) * action="get_sensor_uuid": Get UUID for a sensor name (required before incident_report_agent) - For get_incidents, optional parameters: * start_time, end_time: ISO 8601 format * incident_type: filter by type (e.g., "collision") * max_count: number of incidents (default 10) - Use when: User asks "show alerts", "list incidents", "start/stop monitoring" 6. report_agent (for UPLOADED video reports): - Generates comprehensive reports for uploaded/full videos - Required parameters: * sensor_id: Sensor name (e.g., "warehouse_01.mp4") * user_query: The user's request - Use when: User asks for report on an UPLOADED video file - Returns: Report URLs (markdown + PDF) + video analysis - NOTE: For INCIDENT analysis, use video_understanding directly with time range === PARAMETER HANDLING === PARAMETER EXTRACTION: - If sensor_id is not provided, use get_sensor_names to show available sensors and ask user to choose TIMESTAMP FORMAT (CRITICAL): When calling ANY tool that requires timestamps (start_time, end_time), you MUST use this exact format: YYYY-MM-DDTHH:MM:SS.sssZ. If user does not specify time range when asking to retrieve a video or to generate a report, assume start_time=jan 1 2025 and end_time is the current time. Otherwise, use the time range provided by the user. Examples: - 2020-01-01T00:00:00.000Z - 2025-11-12T14:19:30.000Z NEVER use formats like "2020-01-01T00:00:00+00:00" or "2020-01-01T00:00:00Z" ALWAYS include milliseconds (.000) and use Z for timezone RESPONSE FORMAT (CRITICAL): When you have completed all necessary tool calls and have the final answer: 1. Keep your reasoning/thinking brief and analytical 2. After your analysis, provide ONLY the clean, formatted answer for the user 3. Do NOT include reasoning phrases like "I should", "Let me", "The user", etc. in your final answer 4. Present information directly and professionally Example for sensor query: - Your thinking: "User asked for sensors. Tool returned 30 sensors. I should format them as a list." - Your answer: "Here are the available sensors:\n\n1. SENSOR_NAME_1\n2. SENSOR_NAME_2\n..." postprocessing: enabled: true validation_order: - [url_validator] validators: url_validator: internal_ip: ${HOST_IP} timeout: 10.0 # Template variable: {issues} - list of failed URLs feedback_template: | The following URLs are not accessible: {issues} You have a hallucinated URL in the response, consider removing it or calling the appropriate tool to fetch the correct URL. ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vss-agent/configs/va_mcp_server_config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. functions: vst_sensor_list: _type: vst.sensor_list vst_internal_url: http://${HOST_IP}:30888 function_groups: video_analytics: _type: video_analytics es_url: "http://${HOST_IP}:9200" index_prefix: "mdx-" vlm_verified: true vst_sensor_list_tool: vst_sensor_list embedding_model_name: "sentence-transformers/all-MiniLM-L6-v2" include: - get_incident - get_incidents - get_sensor_ids - get_places - get_fov_histogram - get_average_speeds - analyze llms: nim_llm: _type: nim model_name: meta/llama-3.1-70b-instruct temperature: 0.0 max_tokens: 1024 # dummy workflow required to start MCP server workflow: _type: react_agent tool_names: [video_analytics] llm_name: nim_llm verbose: true parse_agent_response_max_retries: 3 ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vss-behavior-analytics/configs/vss-behavior-analytics-kafka-config.json ================================================ { "kafka": { "brokers": "localhost:9092", "topics": [ { "name": "raw", "value": "mdx-raw" }, { "name": "notification", "value": "mdx-notification" }, { "name": "incidents", "value": "mdx-incidents" } ], "consumer": { "autoOffsetReset": "latest", "enableAutoCommit": false, "maxPollIntervalMs": 900000, "maxPartitionFetchBytes": 10485760, "fetchMaxBytes": 104857600, "maxPollRecords": 10000, "timeout": 0.01 }, "producer": { "lingerMs": 0 }, "group": "mdx-spatial-analytics-2d-app" }, "sensors": [], "app": [ { "name": "playbackLoop", "value": "10000" }, { "name": "playbackFilterEmptyObjects", "value": "true" }, { "name": "playbackStartUpDelaySec", "value": "90" }, { "name": "sourceType", "value": "kafka" }, { "name": "sinkType", "value": "kafka" }, { "name": "numWorkersForIncidentGeneration", "value": "2" }, { "name": "fovCountViolationIncidentEnable", "value": "true" }, { "name": "fovCountViolationIncidentObjectThreshold", "value": "1" }, { "name": "fovCountViolationIncidentThreshold", "value": "2" }, { "name": "fovCountViolationIncidentExpirationWindow", "value": "0.5" }, { "name": "fovCountViolationIncidentObjectType", "value": "person" } ] } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vss-behavior-analytics/configs/vss-behavior-analytics-redis-config.json ================================================ { "redisStream": { "host": "localhost", "port": 6379, "streams": [ { "name": "raw", "value": "mdx-raw" }, { "name": "frames", "value": "mdx-frames" }, { "name": "behavior", "value": "mdx-behavior" }, { "name": "notification", "value": "mdx-notification" }, { "name": "events", "value": "mdx-events" }, { "name": "incidents", "value": "mdx-incidents" } ], "consumer": { "readCount": 200, "readBlockMs": 100 }, "producer": { "maxLen": 10000 }, "group": "mdx-spatial-analytics-2d-app" }, "sensors": [], "app": [ { "name": "playbackLoop", "value": "10000" }, { "name": "playbackFilterEmptyObjects", "value": "true" }, { "name": "playbackStartUpDelaySec", "value": "90" }, { "name": "sourceType", "value": "kafka" }, { "name": "sinkType", "value": "kafka" }, { "name": "numWorkersForIncidentGeneration", "value": "2" }, { "name": "fovCountViolationIncidentEnable", "value": "true" }, { "name": "fovCountViolationIncidentObjectThreshold", "value": "1" }, { "name": "fovCountViolationIncidentThreshold", "value": "0.1" }, { "name": "fovCountViolationIncidentExpirationWindow", "value": "0.5" }, { "name": "fovCountViolationIncidentObjectType", "value": "person" } ] } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vss-video-analytics-api/configs/video-analytics-api-kafka-config.json ================================================ { "server":{ "port":8081, "configs":[ { "name":"postBodySizeLimit", "value":"50mb" }, { "name":"amrRetentionInSec", "value":"3" }, { "name":"inSimulationMode", "value":"false" } ] }, "elasticsearch":{ "node":"http://localhost:9200", "indexPrefix": "mdx-", "rawIndex": "mdx-raw-*" }, "kafka":{ "brokers": ["localhost:9092"] } } ================================================ FILE: deployments/developer-workflow/dev-profile-alerts/vss-video-analytics-api/configs/video-analytics-api-redis-config.json ================================================ { "server":{ "port":8081, "configs":[ { "name":"postBodySizeLimit", "value":"50mb" }, { "name":"amrRetentionInSec", "value":"3" }, { "name":"inSimulationMode", "value":"false" } ] }, "elasticsearch":{ "node":"http://localhost:9200", "indexPrefix": "mdx-", "rawIndex": "mdx-raw-*" }, "kafka":{ "brokers": null } } ================================================ FILE: deployments/developer-workflow/dev-profile-base/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/developer-workflow/dev-profile-base/vss-agent/configs/config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. general: use_uvloop: true front_end: _type: fastapi runner_class: vss_agents.api.custom_fastapi_worker.CustomFastApiFrontEndWorker object_store: ${VSS_AGENT_OBJECT_STORE_TYPE} # Set to local_object_store or remote_object_store endpoints: - path: /api/v1/videos method: POST description: Generate VST upload URL function_name: video_upload_url cors: allow_origins: ['*'] allow_methods: ['*'] allow_headers: ['*'] allow_credentials: false telemetry: tracing: phoenix: _type: phoenix endpoint: ${PHOENIX_ENDPOINT}/v1/traces project: DEV-vss-agent-${VSS_AGENT_VERSION} # weave: # _type: weave # project: ${WEAVE_PROJECT} object_stores: local_object_store: _type: in_memory functions: video_upload_url: _type: video_upload_url vst_external_url: ${VST_EXTERNAL_URL} agent_base_url: ${VSS_AGENT_EXTERNAL_URL} video_understanding: _type: video_understanding vlm_name: ${VLM_MODEL_TYPE:-nim}_vlm # e.g. nim_vlm, openai_vlm max_frames: 30 max_fps: 2 # for CR1, max fps is 2 min_pixels: 3136 max_pixels: 8388608 reasoning: false video_url_tool: vst_video_clip time_format: offset vlm_mode: ${VLM_MODE} internal_ip: ${HOST_IP} external_ip: ${EXTERNAL_IP} vst_internal_url: ${VST_INTERNAL_URL} system_prompt: | You are a monitoring system analyzing video footage. Your task is to describe the events in the video in detail or answer the user's question about the video. IMPORTANT: - You must respond only in English and in plain text. - You must respond only in the format specified in the OUTPUT REQUIREMENTS section. - Timestamp must be in pts format, seconds since the start of the video. - Always provide a direct answer to the question asked. - Never return an empty response. If you cannot find what the user is asking about, acknowledge it to the user. vst_video_clip: _type: vst.video_clip vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} vst_snapshot: _type: vst.snapshot vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} vst_video_list: _type: vst.video_list vst_internal_url: ${VST_INTERNAL_URL} video_report_gen: _type: video_report_gen object_store: ${VSS_AGENT_OBJECT_STORE_TYPE} base_url: ${VSS_AGENT_REPORTS_BASE_URL} video_understanding_tool: video_understanding video_url_tool: vst_video_clip picture_url_tool: vst_snapshot vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} # yamllint disable rule:line-length vlm_prompt: | Describe in detail what is happening in this video, including all visible people, vehicles, equipments, objects, actions, and environmental conditions. OUTPUT REQUIREMENTS: [timestamp-timestamp] Description of what is happening. EXAMPLE: [0.0s-4.0s] [4.0s-12.0s] # HITL Configuration - prompts user to confirm/edit VLM prompt before report generation hitl_enabled: true hitl_prompt_llm: ${LLM_MODEL_TYPE:-nim}_llm # LLM for AI-assisted prompt generation e.g. nim_llm, openai_llm hitl_vlm_prompt_template: | **VLM Prompt for Report Generation** **OPTIONS:** • Press Submit (empty) → Approve and generate report • Type a new prompt directly or paste current prompt and edit it manually • Type `/generate ` → AI creates a prompt based on your description • Type `/refine ` → AI modifies the current prompt • Type `/cancel` → Cancel report generation Enter your choice or press Submit to keep current value: hitl_generate_system_prompt: | You are a prompt engineer specializing in video analysis. Create a clear, detailed prompt for a Vision Language Model (VLM) that will analyze video footage and generate a report. Requirements: - Be specific about what to look for in the video - Include instructions to describe events with timestamps in chronological[Xs-Ys] format - Focus on the user's described scenario/goals and create the prompt that will be understandable for the VLM - Keep the prompt concise but detailed Output ONLY the VLM prompt, no explanations. hitl_refine_system_prompt: | You are a prompt engineer specializing in video analysis. Modify the existing VLM prompt based on the user's instructions. Requirements: - Preserve the timestamp format [Xs-Ys] requirement - Maintain the original prompt structure and content and add on to it the user's requested changes Current prompt to modify: {current_prompt} Output ONLY the modified prompt, no explanations. # yamllint enable rule:line-length report_agent: _type: report_agent log_level: INFO # No Video Analytics MCP tools configured = Video(uploaded) Report mode video_report_tool: video_report_gen llms: # --- LLM profiles (selected by LLM_MODEL_TYPE) --- nim_llm: _type: nim model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 openai_llm: _type: openai model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 # --- VLM profiles (selected by VLM_MODEL_TYPE) --- nim_vlm: _type: nim model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 openai_vlm: _type: openai model_name: ${VLM_NAME} # uses https://api.openai.com/v1 by default temperature: 0.0 max_tokens: 4096 vllm_vlm: _type: openai model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 rtvi_vlm: _type: openai model_name: nim_nvidia_cosmos-reason2-8b_hf-1208 base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 model_kwargs: extra_body: num_frames_per_second_or_fixed_frames_chunk: 20 use_fps_for_chunking: false vlm_input_width: 1280 vlm_input_height: 720 # --- Others --- eval_llm_judge: _type: nim model_name: ${EVAL_LLM_JUDGE_NAME} base_url: ${EVAL_LLM_JUDGE_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 workflow: _type: top_agent llm_name: ${LLM_MODEL_TYPE:-nim}_llm log_level: INFO max_iterations: 50 llm_reasoning: false planning_enabled: true tool_names: - video_understanding - vst_video_clip - vst_snapshot - vst_video_list subagent_names: - report_agent # yamllint disable rule:line-length prompt: | You are a routing agent for a video surveillance system. Your job is to route requests to the correct tool. ## Routing Rules: **vst_video_list** - Videos can be added/removed from the backend system. dont rely on previous conversations for questions about available videos. - If user ask to show a video not in the list from previous interactions, call this tool to verify if the video is available then call the appropriate tool to show the video or snapshot. - Examples: "What videos are available?", "List all available videos" "list sensors" **report_agent** - Only call report_agent when user mentions "report" in the question. - Don't use the report from previous interactions. - Examples: "Generate a report for, "Create an analysis report", I need a safety report for this video" **video_understanding** - For any question about video content. - Examples: "what happens in the video?", "Was there any breach of safety protocol?", "Were there any vehicle collisions?", "Is the bridge in good condition?" **vst_video_clip** - For queries asking for showing videos (e.g., "Let's show the videos just uploaded"), call this tool in parallel with each video name as a separate input. - CRITICAL: Always call vst_video_clip to get the URL even though the video clip for the same video has already been generated from previous interactions. DO NOT generate the URL yourself without calling vst_video_clip. **vst_snapshot** - For queries asking for a snapshot of a video at a specific timestamp. - CRITICAL: Always call vst_snapshot to get the URL even though the snapshot for the same video and at the same timestamp has already been generated from previous interactions. DO NOT generate the URL yourself without calling vst_snapshot. ## Context: Use user's previous uploaded video if user doesn't specify the video name. tool_call_prompt: | TOOL CALL RULES: - ALWAYS include ALL required parameters when calling tools - If a tool call fails with a validation error, RETRY IMMEDIATELY: Fix the error and call the tool again with correct parameters. - Error Handling: if a tool fails more than 3 times, just return a summarized error message. Do not endlessly try again. - Video urls could contain suffix like _20250101_*, you should remove the suffix before matching to the video/sensor name. - Use video/sensor names instead of the url to call other tools. response_format_prompt: | - Do not include phrases like "I should", "Let me", "The user". - Convert json to markdown format. - EXCEPTION: For queries about listing sensors, output sensors as plain text, NOT as code blocks or markdown code. - Wrap urls in html tags. CRITICAL: ONLY do this when a url is EXACTLY the one returned from a tool call or the url is EXACTLY the same as one from the conversation history. NEVER generate urls yourself. NEVER modify an existing url to create a new url (e.g. changing timestamps, IDs, or any part of the url). If you need a url that wasn't returned by a tool call, you MUST call the appropriate tool to get it. Examples: Image NameImage Name postprocessing: enabled: true validation_order: - [url_validator] validators: url_validator: internal_ip: ${HOST_IP} timeout: 10.0 # Template variable: {issues} - list of failed URLs feedback_template: | The following URLs are not accessible: {issues} You have a hallucinated URL in the response, consider removing it or calling the appropriate tool to fetch the correct URL. # yamllint enable rule:line-length eval: general: workflow_alias: ${WEAVE_WORKFLOW_ALIAS} output_dir: ${EVAL_OUTPUT_DIR:-${EVAL_DIR}/results} max_concurrency: 5 dataset: _type: json file_path: ${DATASET_DIR:-${EVAL_DIR}}/${DATASET_FILE_NAME} structure: question_key: "query" answer_key: "ground_truth" profiler: compute_llm_metrics: true evaluators: trajectory_evaluator: _type: customized_trajectory_evaluator llm_name: eval_llm_judge evaluation_method_id: trajectory track_agent_selected_tools_only: true max_retries: 2 llm_judge_reasoning: true # Prompt used when the dataset item has trajectory_ground_truth (reference). # The evaluator auto-detects which prompt to use per item. custom_prompt_template_with_reference: | You are an expert evaluator comparing an AI agent's actual tool calls against the expected ground truth. Question: {question} Expected Tool Calls: {reference} Actual Tool Calls: {agent_trajectory} Agent's Final Answer: {answer} EVALUATION CRITERIA: 1. **Tool Selection** (most important): - Compare the tool NAMES in actual vs expected. - Each expected tool must appear in actual. Extra tools not in expected are unnecessary calls. 2. **Parameter Accuracy**: - For each matching tool, compare the parameter values. - `user_prompt` (for `video_understanding`): If the ground truth contains a description like "", the agent's actual `user_prompt` should be a reasonable question that is related to the user question. 3. **Completeness, Order, and Efficiency** (use the `step` field to compare): - Each tool call has a `step` number. Tools with the SAME step number are parallel calls (order among them does not matter). Tools with DIFFERENT step numbers are sequential (step 1 must happen before step 2, etc.). - The agent must have called ALL expected tools in the correct order (tools called in a single step can be called in different order). For each expected step, verify that all tools in that step appear in the agent's actual calls at the same step. - If actual tool calls are empty but expected is not empty, score 0.0. - If the ground truth shows tools at the same step (i.e., parallel), but the agent called them at different steps (i.e., sequentially), penalize for inefficiency. NOTE: If the ground truth contains a single call to video_understanding, and the agent calls an additional vst_video_list or vst_video_clip before the video_understanding call and the video_understanding call is correct, it should be considered as a success (1.0). SCORING GUIDELINES: - 1.0: All expected tools called with correct parameters - 0.8-0.9: All expected tools called, minor parameter differences - 0.6-0.7: Most expected tools called, or one missing/extra tool - 0.4-0.5: Some expected tools called but significant gaps - 0.0-0.3: Wrong tools called, or no tools called when expected Think through your evaluation carefully, then output only a single number (your score from 0.0 to 1.0). # Prompt used when the dataset item has no trajectory_ground_truth (no reference). custom_prompt_template_without_reference: | You are an expert evaluator assessing an AI agent's performance on tool calling. Conversation History (previous turns): {conversation_history} Current Question: {question} Available Tools and Their Schemas: {tool_schemas} Agent's Actions and Tool Calls: {agent_trajectory} Agent's Final Answer: {answer} IMPORTANT: If conversation history is provided, the current question may refer to context from previous turns. Use the conversation history to understand what the agent should be acting on. Evaluation Criteria: 1. **Tool Selection**: Did the agent select the appropriate tools for the task? 2. **Parameter Accuracy**: Were tool parameters correct according to the tool schemas above? Check that all parameters match the expected types, required fields, and descriptions. 3. **Data Retrieval**: Did the agent successfully retrieve the necessary data? Sometimes the data is not available, that should not be considered as a failure. As long as the tools are called correctly, it should be considered as a success. 4. **Completeness**: Did the agent gather all required information to answer the question? 5. **Efficiency**: Did the agent avoid unnecessary or redundant tool calls? UNDERSTANDING THE TRAJECTORY FORMAT: The trajectory is a list of (action, result) pairs showing the agent's reasoning and tool usage: 1. **Agent's Tool Selection Step**: When the tool name is an LLM model name (e.g., "nvidia/nvidia-nemotron-nano-9b-v2"), this represents the agent deciding which tool to call. The result shows which tools the agent chose. 2. **Tool Execution Step**: When the tool name is an actual tool (e.g., "video_understanding", "vst_video_list"), this shows the tool being executed and its result. 3. **Final Answer**: The last entry in the trajectory contains the agent's final response to the user. NOTE: If the trajectory is empty, it means the agent answered directly without calling any tools. This could happen if the agent used memory from previous conversation or the question was simple enough to answer directly. Scoring Guidelines: - 1.0: Perfect execution - all criteria met, accurate answer - 0.8-0.9: Excellent - minor issues but correct answer - 0.6-0.7: Good - some issues but mostly correct - 0.4-0.5: Fair - significant issues, partially correct - 0.0-0.3: Poor - major issues, incorrect or incomplete answer Think through your evaluation carefully, then output only a single number (your score from 0.0 to 1.0). qa_evaluator: _type: customized_qa_evaluator llm_name: eval_llm_judge evaluation_method_id: qa max_retries: 2 llm_judge_reasoning: true custom_prompt_template: | You are an expert evaluator assessing an AI Agent's response accuracy. Question Asked: {question} Agent's Answer: {answer} Ground Truth Answer: {reference} EVALUATION TASK: Compare the agent's answer against the ground truth and determine if they are semantically equivalent. Assign a nuanced score between 0.0 and 1.0. EVALUATION CRITERIA: 1. **Factual Correctness**: Does the agent's answer convey the same factual information as the ground truth? - For Yes/No questions: The boolean value must match exactly. - For counting questions: The number must exactly match the ground truth. - For temporal questions: Allow ±5 seconds tolerance for timestamps. - For descriptive questions: Key facts and details must align. 2. **Completeness**: Does the agent's answer include all key information from the ground truth? - Partial answers should receive partial credit. - Additional correct details beyond ground truth are acceptable. 3. **Semantic Equivalence**: Different phrasings of the same answer are acceptable. - "Yes" and "Yes, a worker dropped one box" are equivalent for a Yes/No question. - "60 seconds" and "at the 1 minute mark" are equivalent. - "No" and "The worker is not wearing a safety vest" are equivalent. SCORING GUIDELINES: - 1.0: Perfect match - answer is factually correct and complete - 0.8-0.9: Essentially correct with minor omissions or slight imprecision - 0.6-0.7: Partially correct - captures main point but missing some details - 0.4-0.5: Mixed - some correct elements but significant errors or omissions - 0.2-0.3: Mostly incorrect but shows some understanding - 0.0-0.1: Completely wrong or irrelevant answer IMPORTANT NOTES: - Focus on SEMANTIC correctness, not exact text matching. OUTPUT: Think through your evaluation step by step, then output ONLY a single decimal number (your score from 0.0 to 1.0) on the final line. report_evaluator: _type: report_evaluator eval_metrics_config_path: ${EVAL_DIR}/report_eval_metrics.yaml reference_base_dir: ${DATASET_DIR:-${EVAL_DIR}}/data/ evaluation_method_id: report object_store: local_object_store report_url_pattern: 'http://[^\s]+/(vss_report_\d{8}_\d{6}\.md)' include_vlm_output: false metric_configs: llm_judge: llm_name: eval_llm_judge max_retries: 2 single_field_comparison_prompt: | You are an expert evaluator assessing semantic similarity between two responses.{field_context} Reference: {reference} Agent-Generated Response: {actual} Instructions: - Compare the semantic meaning and content between Reference and Agent-Generated Response - For structured data (dicts/JSON): Compare field-by-field across the structure * Account for missing fields (penalize incomplete responses) * Account for extra fields (minor penalty for unexpected fields) * Field names may differ, focus on matching content semantically General Scoring Guidelines: - 1.0: Semantically identical or equivalent (same meaning, minor wording differences acceptable) - 0.8-0.9: Very similar with same key information, minor details may differ - 0.6-0.7: Mostly similar with same core meaning, but some details differ or are missing - 0.4-0.5: Partially similar, captures some aspects but missing important information - 0.2-0.3: Minimally similar, only tangentially related - 0.0-0.1: Completely different or contradictory Consider: - Semantic equivalence (e.g., "03/10/2025" vs "March 10, 2025") - Completeness of information (missing fields reduce score) - Factual accuracy - Extra fields in response (small penalty, unless completely wrong) - Ignore minor formatting or stylistic differences You must respond ONLY with a single float number between 0.0 and 1.0. multi_field_discovery_prompt: | You are evaluating fields in a section of a generated report. Reference section: {reference_section} Actual fields to score: {actual_fields} INSTRUCTIONS: 1. Score ONLY the TOP-LEVEL field names shown in "Actual fields to score" 2. If a field contains nested content (dict/object), evaluate the ENTIRE structure as ONE score 3. Compare each top-level field with the entire Reference section to find semantic matches Steps for each top-level field: - Compare the field name AND its content (including all nested data) with fields in the Reference section - If a match is found (even with different name), identify the matching reference field name - If no match exists in reference, set reference_field to null and score 0.0 - For nested structures, compare the entire object holistically Scoring Guidelines: - 1.0: Perfect match with a reference field (all nested content matches) - 0.8-0.9: Very close match (minor differences in nested content) - 0.6-0.7: Good match (mostly correct, some nested fields differ) - 0.4-0.5: Partial match (some nested information correct) - 0.2-0.3: Poor match (major differences in nested structure) - 0.0: No corresponding field in reference or completely wrong ================================================ FILE: deployments/developer-workflow/dev-profile-lvs/Dockerfiles/kibana-dashboard.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM alpine:3.23.2 # Create a working directory WORKDIR /opt/mdx/ # Copy the init scripts into the working directory COPY ./kibana-dashboard ./ # Install bash and curl commands. RUN apk update && apk add bash RUN apk --no-cache add curl ================================================ FILE: deployments/developer-workflow/dev-profile-lvs/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: kibana-init-container-lvs: build: context: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-lvs dockerfile: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-lvs/Dockerfiles/kibana-dashboard.Dockerfile network_mode: "host" profiles: ["bp_developer_lvs_2d"] container_name: mdx-kibana-init-lvs command: bash /opt/mdx/init-scripts/kibana-import-dashboard.sh restart: on-failure ================================================ FILE: deployments/developer-workflow/dev-profile-lvs/kibana-dashboard/init-scripts/kibana-import-dashboard.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -e # KIBANA CONNECTION VARIABLES KB_CONNECTION_RETRY_ATTEMPTS=0 KB_CONNECTION_MAX_ATTEMPTS=10 KB_URL="http://localhost:5601" # ES CONNECTION VARIABLES ES_CONNECTION_RETRY_ATTEMPTS=0 ES_CONNECTION_MAX_ATTEMPTS=10 ES_URL="http://localhost:9200" ################################# ## function: check_ES_status ################################# check_ES_status(){ echo "Attempting to connect to the Elasticsearch server." # Wait for ES to come up until $(curl --output /dev/null --silent --head --fail -XGET $ES_URL); do if [ ${ES_CONNECTION_RETRY_ATTEMPTS} -eq ${ES_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to ES reached." fi ES_CONNECTION_RETRY_ATTEMPTS=$(($ES_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to ES. Trying to reconnect - (attempt $ES_CONNECTION_RETRY_ATTEMPTS/$ES_CONNECTION_MAX_ATTEMPTS)" sleep 5 done } ################################# ## function: check_kibana_status ################################# check_kibana_status(){ echo "Attempting to connect to the Kibana." # Wait for ES to come up until $(curl --output /dev/null --silent --head --fail -XGET $KB_URL); do if [ ${KB_CONNECTION_RETRY_ATTEMPTS} -eq ${KB_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to Kibana reached." fi KB_CONNECTION_RETRY_ATTEMPTS=$(($KB_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to Kibana. Trying to reconnect - (attempt $KB_CONNECTION_RETRY_ATTEMPTS/$KB_CONNECTION_MAX_ATTEMPTS)." sleep 5 done } ############################ ## function: exit_with_msg ############################ exit_with_msg(){ echo -e "$1 \nExiting Script." exit 1 } ############################## ## function: import_dashboard ############################## import_dashboard(){ echo -e "Importing Dashboards" curl -X POST localhost:5601/api/saved_objects/_import?overwrite=true \ -H "kbn-xsrf: true" \ --form file=@"/opt/mdx/lvs-kibana-objects.ndjson" || exit_with_msg "Curl command to import kibana dashboard failed with failed with error code $?." } ###################### ## Main ###################### main(){ check_ES_status check_kibana_status # Wait for ES and Kibana initizaliztion to avoid startup raise conditions. sleep 10 import_dashboard } main ================================================ FILE: deployments/developer-workflow/dev-profile-lvs/kibana-dashboard/lvs-kibana-objects.ndjson ================================================ {"attributes":{"buildNum":92366,"defaultIndex":"f13306ed-4151-4b8f-9576-e5cfa3b0a74e","isDefaultIndexMigrated":true,"timelion:es.default_index":"lvs-events"},"coreMigrationVersion":"8.8.0","created_at":"2026-01-16T15:42:11.449Z","id":"9.2.2","managed":false,"references":[],"type":"config","typeMigrationVersion":"10.2.0","updated_at":"2026-01-16T15:55:49.014Z","version":"WzMwLDFd"} {"attributes":{"allowHidden":false,"fieldAttrs":"{}","fieldFormatMap":"{}","fields":"[]","name":"lvs-events","runtimeFieldMap":"{}","sourceFilters":"[]","title":"lvs-events"},"coreMigrationVersion":"8.8.0","created_at":"2026-01-16T15:55:07.815Z","id":"f13306ed-4151-4b8f-9576-e5cfa3b0a74e","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"8.0.0","updated_at":"2026-01-16T15:55:07.815Z","version":"WzUsMV0="} {"attributes":{"buildNum":92366,"isDefaultIndexMigrated":true,"showSpaceSolutionTour":false},"coreMigrationVersion":"8.8.0","created_at":"2026-01-16T15:53:55.819Z","id":"9.2.2","managed":false,"references":[],"type":"config-global","updated_at":"2026-01-16T15:54:10.183Z","version":"WzExLDFd"} {"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":3,"missingRefCount":0,"missingReferences":[]} ================================================ FILE: deployments/developer-workflow/dev-profile-lvs/vss-agent/configs/config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. general: use_uvloop: true front_end: _type: fastapi runner_class: vss_agents.api.custom_fastapi_worker.CustomFastApiFrontEndWorker object_store: ${VSS_AGENT_OBJECT_STORE_TYPE} # Set to local_object_store or remote_object_store endpoints: - path: /api/v1/videos method: POST description: Generate VST upload URL function_name: video_upload_url cors: allow_origins: ['*'] allow_methods: ['*'] allow_headers: ['*'] allow_credentials: false telemetry: tracing: phoenix: _type: phoenix endpoint: ${PHOENIX_ENDPOINT}/v1/traces project: DEV-LVS-vss-agent-${VSS_AGENT_VERSION} # Uncomment the following to enable Weave experiment tracking: # weave: # _type: weave # project: ${WEAVE_PROJECT} object_stores: local_object_store: _type: in_memory functions: video_upload_url: _type: video_upload_url vst_external_url: ${VST_EXTERNAL_URL} agent_base_url: ${VSS_AGENT_EXTERNAL_URL} video_understanding: _type: video_understanding vlm_name: ${VLM_MODEL_TYPE:-nim}_vlm max_frames: 60 max_fps: 2 # for CR1, max fps is 2 min_pixels: 3136 max_pixels: 12845056 reasoning: false video_url_tool: vst_video_clip time_format: offset vlm_mode: ${VLM_MODE} internal_ip: ${HOST_IP} external_ip: ${EXTERNAL_IP} vst_internal_url: ${VST_INTERNAL_URL} system_prompt: | You are a monitoring system analyzing video footage. Your task is to describe the events in the video in detail or answer the user's question about the video. IMPORTANT: - You must respond only in English and in plain text. - You must respond only in the format specified in the OUTPUT REQUIREMENTS section. - Timestamp must be in pts format, seconds since the start of the video. - Always provide a direct answer to the question asked. - Never return an empty response. If you cannot find what the user is asking about, acknowledge it to the user. vst_video_duration: _type: vst.duration vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} vst_video_clip: _type: vst.video_clip vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} vst_snapshot: _type: vst.snapshot vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} vst_video_list: _type: vst.video_list vst_internal_url: ${VST_INTERNAL_URL} lvs_video_understanding: _type: lvs_video_understanding lvs_backend_url: ${LVS_BACKEND_URL} model: ${VLM_NAME} video_url_tool: vst_video_clip vlm_mode: ${VLM_MODE} internal_ip: ${HOST_IP} external_ip: ${EXTERNAL_IP} vst_internal_url: ${VST_INTERNAL_URL} # Connection and timeout configuration conn_timeout_ms: 5000 read_timeout_ms: 600000 # Video processing parameters for better event detection chunk_duration: 10 # Split video into 10-second chunks (0 = entire video in one request) num_frames_per_chunk: 20 # Sample 20 frames per chunk # HITL Templates (shown each for each query to the user during interaction) hitl_scenario_template: | Scenario (REQUIRED): Please provide a scenario description for the video analysis. Example: "traffic monitoring", "warehouse monitoring" Enter your scenario or press Submit to keep current value: hitl_events_template: | Events (REQUIRED): Please provide a comma-separated list of events to detect. Examples: - accident, pedestrian crossing, vehicle crossing, traffic violation - boxes falling, accident, forklift stuck, workers not wearing PPE, person entering restricted area Enter events (comma-separated) or press Submit to keep current value: hitl_objects_template: | Objects of Interest (OPTIONAL): Please provide a comma-separated list of objects to focus on, or write "skip" to skip. Examples: - cars, trucks, pedestrians - forklifts, pallets, workers Enter objects (comma-separated), "skip" to skip or press Submit to keep current value: hitl_confirmation_template: | Please review the above configuration that will be sent for video analysis. **Options:** - Press Submit (empty) → Confirm and proceed with video analysis - Type `/redo` → Modify parameters - Type `/cancel` → Cancel analysis Enter your choice or press Submit to proceed: # Default values default_scenario: "traffic monitoring" default_events: - accident - pedestrian crossing - vehicle crossing - traffic violation video_report_gen: _type: video_report_gen object_store: ${VSS_AGENT_OBJECT_STORE_TYPE} base_url: ${VSS_AGENT_REPORTS_BASE_URL} video_understanding_tool: video_understanding lvs_video_understanding_tool: lvs_video_understanding video_url_tool: vst_video_clip picture_url_tool: vst_snapshot vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} vlm_prompt: | Describe in detail what is happening in this video, including all visible people, vehicles, equipments, objects, actions, and environmental conditions. OUTPUT REQUIREMENTS: [timestamp-timestamp] Description of what is happening. EXAMPLE: [0.0s-4.0s] [4.0s-12.0s] report_agent: _type: report_agent log_level: INFO # No Video Analytics MCP tools configured = Video(uploaded) Report mode video_report_tool: video_report_gen llms: # --- LLM profiles (selected by LLM_MODEL_TYPE) --- nim_llm: _type: nim model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 openai_llm: _type: openai model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 # --- VLM profiles (selected by VLM_MODEL_TYPE) --- nim_vlm: _type: nim model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 openai_vlm: _type: openai model_name: ${VLM_NAME} temperature: 0.0 max_tokens: 4096 vllm_vlm: _type: openai model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 # --- Others --- eval_llm_judge: _type: nim model_name: ${EVAL_LLM_JUDGE_NAME} base_url: ${EVAL_LLM_JUDGE_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 workflow: _type: top_agent llm_name: ${LLM_MODEL_TYPE:-nim}_llm log_level: INFO max_iterations: 30 llm_reasoning: false planning_enabled: true tool_names: - video_understanding - vst_video_clip - vst_snapshot - vst_video_list - vst_video_duration subagent_names: - report_agent # yamllint disable rule:line-length prompt: | You are a routing agent for a video surveillance system. Your job is to route requests to the correct tool. ## Routing Rules: **vst_video_list** - - Videos can be added/removed from the backend system. dont rely on previous conversations for questions about available videos. - If user ask to show a video not in the list from previous interactions, call this tool to verify if the video is available then call the appropriate tool to show the video or snapshot. - Examples: "What videos are available?", "List all available videos" "list sensors" **report_agent** - Only call report_agent when user mentions "report" in the question. - Don't use the report from previous interactions. - Examples: "Generate a report for, "Create an analysis report", "Generate a report using long video summarization", "Generate a report using lvs" **video_understanding** - For any question about short videos or quick queries for a video clip. - Use when video is less than 1 minute. - Use when user asks "what happens in the video" or "describe the video" or "analyze this video" or "what is happening in the video" - Do NOT use when user asks to "generate a report" **vst_video_clip** - For queries asking for showing videos (e.g., "Let's show the videos just uploaded"), call this tool in parallel with each video name as a separate input. - CRITICAL: Always call vst_video_clip to get the URL even though the video clip for the same video has already been generated from previous interactions. DO NOT generate the URL yourself without calling vst_video_clip. **vst_snapshot** - For queries asking for a snapshot of a video at a specific timestamp. - CRITICAL: Always call vst_snapshot to get the URL even though the snapshot for the same video and at the same timestamp has already been generated from previous interactions. DO NOT generate the URL yourself without calling vst_snapshot. ## Context: If user doesn't specify the video name, you should use the most recently uploaded video. tool_call_prompt: | - ALWAYS include ALL required parameters when calling tools - If a tool call fails with a validation error, RETRY IMMEDIATELY: Fix the error and call the tool again with correct parameters. - Error Handling: if a tool fails more than 3 times, just return a summarized error message. Do not endlessly try again. response_format_prompt: | - Do not include phrases like "I should", "Let me", "The user". - Convert json to markdown format. - EXCEPTION: For queries about listing sensors, output sensors as plain text, NOT as code blocks or markdown code. - Wrap urls in html tags. CRITICAL: ONLY do this when a url is EXACTLY the one returned from a tool call or the url is EXACTLY the same as one from the conversation history. NEVER generate urls yourself. NEVER modify an existing url to create a new url (e.g. changing timestamps, IDs, or any part of the url). If you need a url that wasn't returned by a tool call, you MUST call the appropriate tool to get it. Examples: Image NameImage Name postprocessing: enabled: true validation_order: - [url_validator] validators: url_validator: internal_ip: ${HOST_IP} timeout: 10.0 # Template variable: {issues} - list of failed URLs feedback_template: | The following URLs are not accessible: {issues} You have a hallucinated URL in the response, consider removing it or calling the appropriate tool to fetch the correct URL. # yamllint enable rule:line-length eval: general: workflow_alias: ${WEAVE_WORKFLOW_ALIAS} output_dir: ${EVAL_DIR}/results dataset: _type: json file_path: ${EVAL_DIR}/dataset.json structure: question_key: "query" evaluators: trajectory_evaluator: _type: customized_trajectory_evaluator llm_name: eval_llm_judge track_agent_selected_tools_only: true max_retries: 2 custom_prompt_template: | You are an expert evaluator assessing an AI agent's performance on tool calling. Question: {question} Available Tools and Their Schemas: {tool_schemas} Agent's Actions and Tool Calls: {agent_trajectory} Agent's Final Answer: {answer} Reference/Expected Output: {reference} Evaluation Criteria: 1. **Tool Selection**: Did the agent select the appropriate tools for the task? 2. **Parameter Accuracy**: Were tool parameters correct according to the tool schemas above? Check that all parameters match the expected types, required fields, and descriptions. 3. **Data Retrieval**: Did the agent successfully retrieve the necessary data? Sometimes the data is not available, that should not be considered as a failure. As long as the tools are called correctly, it should be considered as a success. 4. **Completeness**: Did the agent gather all required information to answer the question? 5. **Efficiency**: Did the agent avoid unnecessary or redundant tool calls? IMPORTANT NOTES: 1. VLM Failure: The report_agent always will fail on VLM analysis. This is EXPECTED and should NOT be considered a failure. As long as the correct tools are called, it should be considered as a success and should be scored 1.0. 2. LLM Model Names in Trajectory: Some trajectory steps have tool names equal to LLM model names. These are NOT actual tool calls. They are INTERNAL REASONING STEPS showing which LLM is thinking. **CRITICAL: When evaluating tool selection, COMPLETELY IGNORE any steps where the tool name is an LLM model name. Only evaluate steps where actual function tools are called.** Scoring Guidelines: - 1.0: Perfect execution - all criteria met, accurate answer - 0.8-0.9: Excellent - minor issues but correct answer - 0.6-0.7: Good - some issues but mostly correct - 0.4-0.5: Fair - significant issues, partially correct - 0.0-0.3: Poor - major issues, incorrect or incomplete answer Think through your evaluation carefully, then output only a single number (your score from 0.0 to 1.0). ================================================ FILE: deployments/developer-workflow/dev-profile-search/Dockerfiles/kibana-dashboard.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM alpine:3.23.2 # Create a working directory WORKDIR /opt/mdx/ # Copy the init scripts into the working directory COPY ./kibana-dashboard ./ # Install bash and curl commands. RUN apk update && apk add bash RUN apk --no-cache add curl ================================================ FILE: deployments/developer-workflow/dev-profile-search/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. include: - path: ./video-analytics-2d-app/compose.yml services: kibana-init-container-search: build: context: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-search dockerfile: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-search/Dockerfiles/kibana-dashboard.Dockerfile network: "host" network_mode: "host" profiles: ["bp_developer_search_2d"] container_name: mdx-kibana-init-search command: bash /opt/mdx/init-scripts/kibana-import-dashboard.sh depends_on: kibana: condition: service_healthy ================================================ FILE: deployments/developer-workflow/dev-profile-search/kibana-dashboard/init-scripts/kibana-import-dashboard.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -e # KIBANA CONNECTION VARIABLES KB_CONNECTION_RETRY_ATTEMPTS=0 KB_CONNECTION_MAX_ATTEMPTS=10 KB_URL="http://localhost:5601" # ES CONNECTION VARIABLES ES_CONNECTION_RETRY_ATTEMPTS=0 ES_CONNECTION_MAX_ATTEMPTS=10 ES_URL="http://localhost:9200" ################################# ## function: check_ES_status ################################# check_ES_status(){ echo "Attempting to connect to the Elasticsearch server." # Wait for ES to come up until $(curl --output /dev/null --silent --head --fail -XGET $ES_URL); do if [ ${ES_CONNECTION_RETRY_ATTEMPTS} -eq ${ES_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to ES reached." fi ES_CONNECTION_RETRY_ATTEMPTS=$(($ES_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to ES. Trying to reconnect - (attempt $ES_CONNECTION_RETRY_ATTEMPTS/$ES_CONNECTION_MAX_ATTEMPTS)" sleep 5 done } ################################# ## function: check_kibana_status ################################# check_kibana_status(){ echo "Attempting to connect to the Kibana." # Wait for ES to come up until $(curl --output /dev/null --silent --head --fail -XGET $KB_URL); do if [ ${KB_CONNECTION_RETRY_ATTEMPTS} -eq ${KB_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to Kibana reached." fi KB_CONNECTION_RETRY_ATTEMPTS=$(($KB_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to Kibana. Trying to reconnect - (attempt $KB_CONNECTION_RETRY_ATTEMPTS/$KB_CONNECTION_MAX_ATTEMPTS)." sleep 5 done } ############################ ## function: exit_with_msg ############################ exit_with_msg(){ echo -e "$1 \nExiting Script." exit 1 } ############################## ## function: import_dashboard ############################## import_dashboard(){ echo -e "Importing Dashboards" curl -X POST localhost:5601/api/saved_objects/_import?overwrite=true \ -H "kbn-xsrf: true" \ --form file=@"/opt/mdx/search-kibana-objects.ndjson" || exit_with_msg "Curl command to import kibana dashboard failed with failed with error code $?." } ###################### ## Main ###################### main(){ check_ES_status check_kibana_status # Wait for ES and Kibana initizaliztion to avoid startup raise conditions. sleep 10 import_dashboard } main ================================================ FILE: deployments/developer-workflow/dev-profile-search/kibana-dashboard/search-kibana-objects.ndjson ================================================ {"attributes":{"buildNum":92366,"dateFormat:tz":"UTC","defaultIndex":"2b2760e8-c3c2-44fb-82f7-5a12e509b810","isDefaultIndexMigrated":true,"timelion:es.default_index":"mdx-embed-filtered-*"},"coreMigrationVersion":"8.8.0","created_at":"2026-01-15T07:06:37.960Z","id":"9.2.2","managed":false,"references":[],"type":"config","typeMigrationVersion":"10.2.0","updated_at":"2026-01-16T05:01:33.886Z","version":"WzcxOSwxXQ=="} {"attributes":{"allowHidden":false,"fieldAttrs":"{}","fieldFormatMap":"{}","fields":"[]","name":"mdx-embed-filtered-*","runtimeFieldMap":"{}","sourceFilters":"[]","timeFieldName":"","title":"mdx-embed-filtered-*"},"coreMigrationVersion":"8.8.0","created_at":"2026-01-16T03:18:02.614Z","id":"2b2760e8-c3c2-44fb-82f7-5a12e509b810","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"8.0.0","updated_at":"2026-01-16T04:59:22.953Z","version":"WzQwLDFd"} {"attributes":{"buildNum":92366,"isDefaultIndexMigrated":true,"showSpaceSolutionTour":false},"coreMigrationVersion":"8.8.0","created_at":"2026-01-15T07:06:37.961Z","id":"9.2.2","managed":false,"references":[],"type":"config-global","updated_at":"2026-01-15T07:06:43.464Z","version":"WzgsMV0="} {"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":3,"missingRefCount":0,"missingReferences":[]} ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/Dockerfiles/perception-cnn.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # set base image ARG PERCEPTION_IMAGE ARG PERCEPTION_TAG FROM $PERCEPTION_IMAGE:$PERCEPTION_TAG # set the working directory in the container WORKDIR /opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app # copy the dependencies file to the working directory COPY ./deepstream/configs/cnn-models/* ./ # copy the start script and make it executable COPY ./deepstream/init-scripts/ds-start.sh . ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: vss-search-analytics-2d-fusion: image: nvcr.io/nvidia/vss-core/vss-behavior-analytics:3.1.0 network_mode: "host" profiles: ["bp_developer_search_2d"] volumes: - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-search/video-analytics-2d-app/vss-search-analytics/configs/vss-search-analytics-$STREAM_TYPE-config.json:/resources/vss-search-analytics-config.json restart: always container_name: vss-search-analytics-fusion command: python3 apps/fusion_search/main_fusion_search_analytics_app.py --config /resources/vss-search-analytics-config.json depends_on: broker-health-check: condition: service_completed_successfully nvstreamer-2d-fusion: container_name: mdx-nvstreamer-2d image: nvcr.io/nvidia/vss-core/vss-vios-nvstreamer:${NVSTREAMER_IMAGE_TAG} user: "0:0" #runtime: nvidia profiles: ["bp_developer_search_2d"] entrypoint: ["/bin/bash", "-c", "if [ \"$$NVSTREAMER_INSTALL_ADDITIONAL_PACKAGES\" = \"true\" ]; then /home/vst/vst_release/tools/user_additional_install.sh; fi && exec /home/vst/vst_release/launch_vst"] environment: - NVSTREAMER_INSTALL_ADDITIONAL_PACKAGES=${NVSTREAMER_INSTALL_ADDITIONAL_PACKAGES} - ADAPTOR=streamer - HTTP_PORT=${NVSTREAMER_HTTP_PORT} network_mode: "host" deploy: restart_policy: condition: on-failure max_attempts: 2 resources: reservations: devices: - capabilities: [gpu] device_ids: ["0"] volumes: - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-search/video-analytics-2d-app/nvstreamer/configs/vst-config.json:/home/vst/vst_release/configs/vst_config.json - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-search/video-analytics-2d-app/nvstreamer/configs/vst-storage.json:/home/vst/vst_release/configs/vst_storage.json - $MDX_DATA_DIR/data_log/nvstreamer/vst_data:/home/vst/vst_release/vst_data depends_on: broker-health-check: condition: service_completed_successfully # perception-sdr-2d-fusion: # image: nvcr.io/nvidia/vss-core/sdr:3.1.0 # profiles: ["bp_developer_search_2d"] # network_mode: "host" # logging: # driver: "json-file" # options: # max-size: "8192m" # max-file: "3" # container_name: perception-sdr-2d # volumes: # - $MDX_SAMPLE_APPS_DIR/warehouse/warehouse-2d-app/sdr:/wdm-configs # - $MDX_SAMPLE_APPS_DIR/warehouse/warehouse-2d-app/sdr:/wdm-data # - /var/run/docker.sock:/var/run/docker.sock # environment: # PORT: 4001 # OTEL_SDK_DISABLED: true # WDM_INITIALIZE_FROM_VST: false # WDM_WL_SPEC: /wdm-data/ds-data_wl.yaml # WDM_CLUSTER_CONFIG_FILE: /wdm-configs/docker_cluster_config.json # WDM_MSG_KEY: vst.event # WDM_WL_REDIS_MSG_FIELD: sensor.id # WDM_WL_ADD_URL: /api/v1/stream/add # WDM_WL_DELETE_URL: /api/v1/stream/remove # WDM_WL_HEALTH_CHECK_URL: /api/v1/stream/add # VST_STREAMS_ENDPOINT: http://localhost:30888/vst/api/v1/live/streams # VST_STATUS_ENDPOINT: http://localhost:30888/vst/api/v1/sensor/status # WDM_WL_CHANGE_ID_ADD: camera_streaming # WDM_PRELOAD_WORKLOAD: ./tests/event_pre-roll.json # WDM_CLEAR_DATA_WL: true # WDM_KFK_ENABLE: true # WDM_DS_SWAP_ID_NAME: false # WDM_VALIDATE_BEFORE_ADD: true # WDM_PRELOAD_DELAY_FOR_DS_API: false # WDM_WL_THRESHOLD: 10 # WDM_CLUSTER_TYPE: docker # WDM_POD_WATCH_DOCKER_DELAY: 0.05 # WDM_DS_STATUS_CHECK: true # WDM_RESTART_DS_ON_ADD_FAIL: false # WDM_DISABLE_WERKZEUG_LOGGING: true # WDM_WL_OBJECT_NAME: sdr-perception # WDM_CONSUMER_GRP_ID: sdr-perception-cg # WDM_CLUSTER_CONTAINER_NAMES: '["perception-2d"]' # WDM_MSG_TOPIC: mdx-notification # WDM_CONFIG_PORT: 9003 # WDM_ADD_REMOVE_RETRY_ATTEMPTS: 100 # WDM_ADD_CALL_DELAY: 10 # WDM_REAPPLY_ON_WL_RESTART: true # WDM_DOCKER_CLUSTER_KEY_DOWN_NAMES: '["perception-2d"]' # WDM_CALL_WL_WEBHOOK: true # WDM_WL_WEBHOOK_ENDPOINT: http://10.21.84.235:8000/api/v1/rtvi-embed/ingest # deploy: # resources: # limits: # memory: 300M # restart_policy: # condition: always # entrypoint: [] # command: sh -c '/wdm/dist/sdr' # depends_on: # broker-health-check: # condition: service_completed_successfully perception-2d-fusion: build: context: $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-search/video-analytics-2d-app args: PERCEPTION_IMAGE: $PERCEPTION_IMAGE PERCEPTION_TAG: $PERCEPTION_TAG dockerfile: Dockerfiles/perception-$MODEL_TYPE.Dockerfile network_mode: "host" runtime: nvidia profiles: ["bp_developer_search_2d"] container_name: perception-2d deploy: restart_policy: condition: on-failure max_attempts: 2 resources: reservations: devices: - capabilities: - gpu device_ids: - "${RT_CV_DEVICE_ID:-0}" volumes: - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-main-config.txt:/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/ds-main-config.txt - $MDX_SAMPLE_APPS_DIR/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-main-redis-config.txt:/opt/nvidia/deepstream/deepstream/sources/apps/sample_apps/metropolis_perception_app/ds-main-redis-config.txt - $MDX_DATA_DIR/models/rtdetr_warehouse_v1.0.1.fp16.onnx:/opt/storage/rtdetr_warehouse_v1.0.1.fp16.onnx - $MDX_DATA_DIR/models/radio-clip_v1.0.onnx:/opt/storage/radio-clip_v1.0.onnx - $MDX_DATA_DIR/models/radio-clip_v1.0_weights.bin:/opt/storage/radio-clip_v1.0_weights.bin - $MDX_DATA_DIR/models/radio-clip_v1.0_tokenizer:/opt/storage/radio-clip_v1.0_tokenizer environment: MODEL_TYPE: ${MODEL_TYPE} STREAM_TYPE: ${STREAM_TYPE} DEEPSTREAM_ENABLE_SENSOR_ID_EXTRACTION: "1" GST_ENABLE_CUSTOM_PARSER_MODIFICATIONS: 1 OTEL_SERVICE_NAME: "rtvi-cv" OTEL_SDK_DISABLED: ${OTEL_SDK_DISABLED:-true} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4318} OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-otlp} GST_PLUGIN_PATH: /opt/nvidia/deepstream/deepstream/sources/gst-plugins/gst-nvdstextembedder TRANSFORMERS_OFFLINE: 0 HF_HUB_OFFLINE: 0 command: ["bash", "-c", "./ds-start.sh"] depends_on: sensor-ms-dev: condition: service_started broker-health-check: condition: service_completed_successfully volumes: mdx-nvstreamer-data: mdx-nvstreamer-videos: perception-2d: ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-detector-labels.txt ================================================ Person Agility_Digit_Humanoid Fourier_GR1_T2_Humanoid Nova_Carter Transporter Forklift Pallet ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-kafka-config.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [message-broker] partition-key = sensorId ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-main-config.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [application] enable-perf-measurement=1 perf-measurement-interval-sec=5 [tiled-display] enable=3 rows=1 columns=2 width=1280 height=720 gpu-id=0 nvbuf-memory-type=0 # Sources [source-list] num-source-bins=0 #list=rtsp://localhost:8555/live/Nth_Street_Cafe_Entrance;rtsp://localhost:8555/live/Endeavor_Cafeteria #sensor-id-list=Nth_Street_Cafe_Entrance;Endeavor_Cafeteria #sensor-name-list=Nth_Street_Cafe_Entrance;Endeavor_Cafeteria # Set use-nvmultiurisrcbin to 1 to enable sensor provisioning/update feature use-nvmultiurisrcbin=1 stream-name-display=1 max-batch-size=8 http-ip=localhost http-port=9000 extract-sei-type5-data=1 sei-uuid=NVDS_CUSTOMMETA #sgie batch size is number of sources * fair fraction of number of objects detected per frame per source #the fair fraction of number of object detected is assumed to be 4 sgie-batch-size=4 [source-attr-all] enable=1 type=3 num-sources=1 gpu-id=0 cudadec-memtype=0 latency=100 drop-on-latency=1 rtsp-reconnect-interval-sec=10 rtsp-reconnect-attempts=-1 udp-buffer-size=2000000 [sink0] enable=0 #Type - 1=FakeSink 2=EglSink 3=File type=1 sync=0 source-id=0 gpu-id=0 nvbuf-memory-type=0 [sink1] enable=1 #Type - 1=FakeSink 2=EglSink 3=File 4=UDPSink 5=nvoverlaysink 6=MsgConvBroker type=6 #msg-conv-config=ds-msgconv-config.txt #(0): PAYLOAD_DEEPSTREAM - Deepstream schema payload #(1): PAYLOAD_DEEPSTREAM_MINIMAL - Deepstream schema payload minimal #(256): PAYLOAD_RESERVED - Reserved type #(257): PAYLOAD_CUSTOM - Custom schema payload msg-conv-payload-type=2 #(0): Create payload using NvdsEventMsgMeta #(1): New Api to create payload using NvDsFrameMeta msg-conv-msg2p-new-api=0 #Frame interval at which payload is generated msg-conv-frame-interval=1 msg-broker-proto-lib=/opt/nvidia/deepstream/deepstream/lib/libnvds_kafka_proto.so #Provide your msg-broker-conn-str here #msg-broker-conn-str=qvs-ds-kafka-01;9092;metromind-raw #topic=metromind-raw # msg-broker-conn-str=mdx-kafka-cluster-kafka-brokers;9092;mdx-raw msg-broker-conn-str=localhost;9092;mdx-raw #topic=mdx-raw topic=mdx-raw #Optional: #msg-broker-config=ds-kafka-config.txt #new-api=0 #(0) Use message adapter library api's #(1) Use new msgbroker library api's nvdslogger=1 [sink2] enable=0 type=3 #1=mp4 2=mkv container=1 #1=h264 2=h265 3=mpeg4 ## only SW mpeg4 is supported right now. codec=3 sync=1 bitrate=2000000 output-file=out.mp4 source-id=0 # sink type = 6 by default creates msg converter + broker. # To use multiple brokers use this group for converter and use # sink type = 6 with disable-msgconv = 1 [message-converter] enable=0 msg-conv-config=ds-msgconv-config.txt #(0): PAYLOAD_DEEPSTREAM - Deepstream schema payload #(1): PAYLOAD_DEEPSTREAM_MINIMAL - Deepstream schema payload minimal #(256): PAYLOAD_RESERVED - Reserved type #(257): PAYLOAD_CUSTOM - Custom schema payload msg-conv-payload-type=0 # Name of library having custom implementation. #msg-conv-msg2p-lib= # Id of component in case only selected message to parse. #msg-conv-comp-id= # Configure this group to enable cloud message consumer. [message-consumer0] enable=0 proto-lib=/opt/nvidia/deepstream/deepstream/lib/libnvds_kafka_proto.so conn-str=; config-file=/opt/nvidia/deepstream/deepstream/sources/libs/kafka_protocol_adaptor/ds-kafka-config.txt subscribe-topic-list=;; # Use this option if message has sensor name as id instead of index (0,1,2 etc.). #sensor-list-file=ds-msgconv-config.txt # Configure this group to enable nvdstextembedder plugin [text-embedder] enable=1 model-name=siglip2-onnx onnx-model-path=/opt/storage/radio-clip_v1.0.onnx tokenizer-dir=/opt/storage/radio-clip_v1.0_tokenizer/ [osd] enable=0 gpu-id=0 border-width=1 text-size=15 text-color=1;1;1;1; text-bg-color=0.3;0.3;0.3;1 font=Arial show-clock=0 clock-x-offset=800 clock-y-offset=820 clock-text-size=12 clock-color=1;0;0;0 nvbuf-memory-type=0 [streammux] gpu-id=0 ##Boolean property to inform muxer that sources are live live-source=1 batch-size=8 ##time out in usec, to wait after the first buffer is available ##to push the batch even if the complete batch is not formed batched-push-timeout=33000 ## Set muxer output width and height width=1920 height=1080 ##Enable to maintain aspect ratio wrt source, and allow black borders, works ##along with width, height properties enable-padding=0 nvbuf-memory-type=0 ## If set to TRUE, system timestamp will be attached as ntp timestamp ## If set to FALSE, ntp timestamp from rtspsrc, if available, will be attached attach-sys-ts-as-ntp=0 drop-pipeline-eos=1 # Enable / Disable both properties for SEI #extract-sei-sim-time=1 #drop-backward-sei=1 # config-file property is mandatory for any gie section. # Other properties are optional and if set will override the properties set in # the infer config file. [primary-gie] enable=1 gpu-id=0 #Required to display the PGIE labels, should be added even when using config-file #property batch-size=8 #Required by the app for OSD, not a plugin property bbox-border-color0=1;0;0;1 bbox-border-color1=0;1;1;1 bbox-border-color2=0;1;1;1 bbox-border-color3=0;1;0;1 interval=1 #Required by the app for SGIE, when used along with config-file property gie-unique-id=1 nvbuf-memory-type=0 config-file=ds-ppl-analytics-pgie-config.yml [tracker] enable=1 # For NvDCF and DeepSORT tracker, tracker-width and tracker-height must be a multiple of 32, respectively tracker-width=960 tracker-height=544 ll-lib-file=/opt/nvidia/deepstream/deepstream/lib/libnvds_nvmultiobjecttracker.so # ll-config-file required to set different tracker types # ll-config-file=/opt/configs/config_tracker_IOU.yml #ll-config-file=/opt/configs/config_tracker_NvDCF_perf.yml ll-config-file=ds-nvdcf-accuracy-tracker-config.yml # ll-config-file=/opt/configs/config_tracker_DeepSORT.yml gpu-id=0 display-tracking-id=1 [visionencoder] enable=1 backend=tensorrt tensorrt-engine=/opt/storage/model_batch16.plan onnx-model=/opt/storage/radio-clip_v1.0.onnx batch-size=3 min-crop-size=32 verbose=1 gpu-id=0 skip-interval=0 [secondary-gie0] enable=0 gpu-id=0 gie-unique-id=2 operate-on-gie-id=1 operate-on-class-ids=0 batch-size=8 config-file=ppl_analytics_sgie_config.txt [tests] file-loop=1 ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-main-redis-config.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [application] enable-perf-measurement=1 perf-measurement-interval-sec=5 [tiled-display] enable=3 rows=1 columns=2 width=1280 height=720 gpu-id=0 nvbuf-memory-type=0 # Sources [source-list] num-source-bins=0 #list=rtsp://localhost:8555/live/Nth_Street_Cafe_Entrance;rtsp://localhost:8555/live/Endeavor_Cafeteria #sensor-id-list=Nth_Street_Cafe_Entrance;Endeavor_Cafeteria #sensor-name-list=Nth_Street_Cafe_Entrance;Endeavor_Cafeteria # Set use-nvmultiurisrcbin to 1 to enable sensor provisioning/update feature use-nvmultiurisrcbin=1 stream-name-display=1 max-batch-size=8 http-ip=localhost http-port=9000 extract-sei-type5-data=1 sei-uuid=NVDS_CUSTOMMETA #sgie batch size is number of sources * fair fraction of number of objects detected per frame per source #the fair fraction of number of object detected is assumed to be 4 sgie-batch-size=4 [source-attr-all] enable=1 type=3 num-sources=1 gpu-id=0 cudadec-memtype=0 latency=100 drop-on-latency=1 rtsp-reconnect-interval-sec=10 rtsp-reconnect-attempts=-1 udp-buffer-size=2000000 [sink0] enable=0 #Type - 1=FakeSink 2=EglSink 3=File type=1 sync=0 source-id=0 gpu-id=0 nvbuf-memory-type=0 [sink1] enable=1 #Type - 1=FakeSink 2=EglSink 3=File 4=UDPSink 5=nvoverlaysink 6=MsgConvBroker type=6 #msg-conv-config=ds-msgconv-config.txt #(0): PAYLOAD_DEEPSTREAM - Deepstream schema payload #(1): PAYLOAD_DEEPSTREAM_MINIMAL - Deepstream schema payload minimal #(256): PAYLOAD_RESERVED - Reserved type #(257): PAYLOAD_CUSTOM - Custom schema payload msg-conv-payload-type=2 #(0): Create payload using NvdsEventMsgMeta #(1): New Api to create payload using NvDsFrameMeta msg-conv-msg2p-new-api=0 #Frame interval at which payload is generated msg-conv-frame-interval=1 msg-broker-proto-lib=/opt/nvidia/deepstream/deepstream/lib/libnvds_redis_proto.so #Provide your msg-broker-conn-str here msg-broker-conn-str=localhost;6379; #topic=mdx-raw topic=mdx-raw #Optional: msg-broker-config=ds-redis-config.txt #new-api=0 #(0) Use message adapter library api's #(1) Use new msgbroker library api's nvdslogger=1 [sink2] enable=0 type=3 #1=mp4 2=mkv container=1 #1=h264 2=h265 3=mpeg4 ## only SW mpeg4 is supported right now. codec=3 sync=1 bitrate=2000000 output-file=out.mp4 source-id=0 # sink type = 6 by default creates msg converter + broker. # To use multiple brokers use this group for converter and use # sink type = 6 with disable-msgconv = 1 [message-converter] enable=0 msg-conv-config=ds-msgconv-config.txt #(0): PAYLOAD_DEEPSTREAM - Deepstream schema payload #(1): PAYLOAD_DEEPSTREAM_MINIMAL - Deepstream schema payload minimal #(256): PAYLOAD_RESERVED - Reserved type #(257): PAYLOAD_CUSTOM - Custom schema payload msg-conv-payload-type=0 # Name of library having custom implementation. #msg-conv-msg2p-lib= # Id of component in case only selected message to parse. #msg-conv-comp-id= # Configure this group to enable cloud message consumer. [message-consumer0] enable=0 proto-lib=/opt/nvidia/deepstream/deepstream/lib/libnvds_kafka_proto.so conn-str=; config-file=/opt/nvidia/deepstream/deepstream/sources/libs/kafka_protocol_adaptor/ds-kafka-config.txt subscribe-topic-list=;; # Use this option if message has sensor name as id instead of index (0,1,2 etc.). #sensor-list-file=ds-msgconv-config.txt # Configure this group to enable nvdstextembedder plugin [text-embedder] enable=1 model-name=siglip2-onnx onnx-model-path=/opt/storage/radio-clip_v1.0.onnx tokenizer-dir=/opt/storage/radio-clip_v1.0_tokenizer/ [osd] enable=0 gpu-id=0 border-width=1 text-size=15 text-color=1;1;1;1; text-bg-color=0.3;0.3;0.3;1 font=Arial show-clock=0 clock-x-offset=800 clock-y-offset=820 clock-text-size=12 clock-color=1;0;0;0 nvbuf-memory-type=0 [streammux] gpu-id=0 ##Boolean property to inform muxer that sources are live live-source=1 batch-size=8 ##time out in usec, to wait after the first buffer is available ##to push the batch even if the complete batch is not formed batched-push-timeout=33000 ## Set muxer output width and height width=1920 height=1080 ##Enable to maintain aspect ratio wrt source, and allow black borders, works ##along with width, height properties enable-padding=0 nvbuf-memory-type=0 ## If set to TRUE, system timestamp will be attached as ntp timestamp ## If set to FALSE, ntp timestamp from rtspsrc, if available, will be attached attach-sys-ts-as-ntp=0 drop-pipeline-eos=1 # Enable / Disable both properties for SEI #extract-sei-sim-time=1 #drop-backward-sei=1 # config-file property is mandatory for any gie section. # Other properties are optional and if set will override the properties set in # the infer config file. [primary-gie] enable=1 gpu-id=0 #Required to display the PGIE labels, should be added even when using config-file #property batch-size=8 #Required by the app for OSD, not a plugin property bbox-border-color0=1;0;0;1 bbox-border-color1=0;1;1;1 bbox-border-color2=0;1;1;1 bbox-border-color3=0;1;0;1 interval=1 #Required by the app for SGIE, when used along with config-file property gie-unique-id=1 nvbuf-memory-type=0 config-file=ds-ppl-analytics-pgie-config.yml [tracker] enable=1 # For NvDCF and DeepSORT tracker, tracker-width and tracker-height must be a multiple of 32, respectively tracker-width=960 tracker-height=544 ll-lib-file=/opt/nvidia/deepstream/deepstream/lib/libnvds_nvmultiobjecttracker.so # ll-config-file required to set different tracker types # ll-config-file=/opt/configs/config_tracker_IOU.yml #ll-config-file=/opt/configs/config_tracker_NvDCF_perf.yml ll-config-file=ds-nvdcf-accuracy-tracker-config.yml # ll-config-file=/opt/configs/config_tracker_DeepSORT.yml gpu-id=0 display-tracking-id=1 [visionencoder] enable=1 backend=tensorrt tensorrt-engine=/opt/storage/model_batch16.plan onnx-model=/opt/storage/radio-clip_v1.0.onnx batch-size=3 min-crop-size=32 verbose=1 gpu-id=0 skip-interval=0 [secondary-gie0] enable=0 gpu-id=0 gie-unique-id=2 operate-on-gie-id=1 operate-on-class-ids=0 batch-size=8 config-file=ppl_analytics_sgie_config.txt [tests] file-loop=1 ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-nvdcf-accuracy-tracker-config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. BaseConfig: minDetectorConfidence: 0.059091767738520054 TargetManagement: enableBboxUnClipping: 1 preserveStreamUpdateOrder: 0 maxTargetsPerStream: 150 minIouDiff4NewTarget: 0.40803391831819774 minTrackerConfidence: 0.2379867160124702 probationAge: 2 maxShadowTrackingAge: 49 earlyTerminationAge: 1 TrajectoryManagement: useUniqueID: 0 enableReAssoc: 1 minMatchingScore4Overall: 0.6491824364015243 minTrackletMatchingScore: 0.3365923298667557 minMatchingScore4ReidSimilarity: 0.41004668162204816 matchingScoreWeight4TrackletSimilarity: 0.8531794819299393 matchingScoreWeight4ReidSimilarity: 0.13580096342687126 minTrajectoryLength4Projection: 33 prepLength4TrajectoryProjection: 53 trajectoryProjectionLength: 99 maxAngle4TrackletMatching: 70 minSpeedSimilarity4TrackletMatching: 0.03284195344815558 minBboxSizeSimilarity4TrackletMatching: 0.40454200672158597 maxTrackletMatchingTimeSearchRange: 24 trajectoryProjectionProcessNoiseScale: 0.01 trajectoryProjectionMeasurementNoiseScale: 100 trackletSpacialSearchRegionScale: 0.01 reidExtractionInterval: 0 DataAssociator: dataAssociatorType: 0 associationMatcherType: 1 checkClassMatch: 1 minMatchingScore4Overall: 0.16498049370431805 minMatchingScore4SizeSimilarity: 0.2470318611174308 minMatchingScore4Iou: 0.09268843253527276 minMatchingScore4VisualSimilarity: 0.5306397916012133 matchingScoreWeight4VisualSimilarity: 0.2593903206246254 matchingScoreWeight4SizeSimilarity: 0.8737211144111663 matchingScoreWeight4Iou: 0.3538715510450657 tentativeDetectorConfidence: 0.07696106106777956 minMatchingScore4TentativeIou: 0.18259431931328785 StateEstimator: stateEstimatorType: 1 processNoiseVar4Loc: 5500.457806079246 processNoiseVar4Size: 2784.2546323956462 processNoiseVar4Vel: 545.3315792468695 measurementNoiseVar4Detector: 100.00000497224985 measurementNoiseVar4Tracker: 294.4755412477389 VisualTracker: visualTrackerType: 1 useColorNames: 1 useHog: 1 featureImgSizeLevel: 3 featureFocusOffsetFactor_y: -0.21492658069764317 filterLr: 0.04747373772798158 filterChannelWeightsLr: 0.06898637782721721 gaussianSigma: 0.7195646880047487 ReID: reidType: 0 outputReidTensor: 0 batchSize: 100 workspaceSize: 1000 reidFeatureSize: 256 reidHistorySize: 100 inferDims: [3, 256, 128] networkMode: 1 inputOrder: 0 colorFormat: 0 offsets: [123.6750, 116.2800, 103.5300] netScaleFactor: 0.01735207 keepAspc: 1 addFeatureNormalization: 1 ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-ppl-analytics-pgie-config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. class-attrs-all: pre-cluster-threshold: 0.5 topk: 20 # Assign different thresholds for different classes for optimal performance class-attrs-0: pre-cluster-threshold: 0.85 topk: 20 class-attrs-1: pre-cluster-threshold: 0.85 class-attrs-2: pre-cluster-threshold: 0.85 class-attrs-3: pre-cluster-threshold: 0.85 class-attrs-4: pre-cluster-threshold: 0.85 class-attrs-5: pre-cluster-threshold: 0.5 class-attrs-6: pre-cluster-threshold: 0.5 property: # 1=DBSCAN, 2=NMS, 3= DBSCAN+NMS Hybrid, 4 = None(No clustering) cluster-mode: 2 gie-unique-id: 1 gpu-id: 0 scaling-filter: 1 infer-dims: 3;640;640 offsets: 0;0;0 interval: 0 labelfile-path: ds-detector-labels.txt maintain-aspect-ratio: 1 model-color-format: 0 net-scale-factor: 0.00392156862745098 network-mode: 2 network-type: 0 model-engine-file: /opt/storage/rtdetr_warehouse_v1.0.1.fp16.onnx_b8_gpu0_fp16.engine onnx-file: /opt/storage/rtdetr_warehouse_v1.0.1.fp16.onnx num-detected-classes: 7 output-tensor-meta: 1 output-blob-names: pred_boxes;pred_logits parse-bbox-func-name: NvDsInferParseCustomRTDETRTAO strongly-typed: 1 custom-lib-path: /opt/nvidia/deepstream/deepstream/lib/libnvds_infercustomparser.so ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/cnn-models/ds-redis-config.txt ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [message-broker] hostname=localhost port=6379 streamsize=10000 payloadkey=value consumergroup=mygroup consumername=myname share-connection=1 # password=password ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/configs/config.csv ================================================ ,,mdx-bev ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/deepstream/init-scripts/ds-start.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. if [[ $MODEL_TYPE == "cnn" ]]; then echo "##### $MODEL_TYPE models will be used. #####" echo -e "\nds pgie configs\n" cat ds-ppl-analytics-pgie-config.yml # Check STREAM_TYPE and run appropriate command if [ "$STREAM_TYPE" = "kafka" ]; then echo "Running metropolis_perception_app with kafka configuration..." echo -e "\nds main configs\n" cat ds-main-config.txt ./metropolis_perception_app -c ds-main-config.txt -m 1 -t 0 -l 5 --message-rate 1 --tracker-reid elif [ "$STREAM_TYPE" = "redis" ]; then echo "Running metropolis_perception_app with redis configuration..." echo -e "\nds main configs\n" cat ds-main-redis-config.txt ./metropolis_perception_app -c ds-main-redis-config.txt -m 1 -t 0 -l 5 --message-rate 1 --tracker-reid else echo "STREAM_TYPE not set or invalid. Defaulting to kafka configuration..." echo -e "\nds main configs\n" cat ds-main-config.txt ./metropolis_perception_app -c ds-main-config.txt -m 1 -t 0 -l 5 --message-rate 1 --tracker-reid fi else echo "##### Invalid value $MODEL_TYPE for MODEL_TYPE variable. Valid values are: 'cnn'. #####" fi ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/nvstreamer/configs/vst-config.json ================================================ { "network": { "http_port": "31000", "server_domain_name": "", "stunurl_list": [ "stun.l.google.com:19302", "stun1.l.google.com:19302" ], "static_turnurl_list": [], "use_coturn_auth_secret": false, "coturn_turnurl_list_with_secret": [], "use_twilio_stun_turn": false, "twilio_account_sid": "", "twilio_auth_token": "", "use_reverse_proxy": false, "reverse_proxy_server_address": "REVERSE_PROXY_SERVER_ADDRESS:100", "ntp_servers": [], "use_sensor_ntp_time": false, "max_webrtc_out_connections": 40, "max_webrtc_in_connections": 1, "webservice_access_control_list": "", "rtsp_server_port": 31554, "rtsp_server_instances_count": 10, "rtsp_preferred_network_iface": "", "rtsp_in_base_udp_port_num": -1, "rtsp_out_base_udp_port_num": -1, "rtsp_streaming_over_tcp": false, "rtsp_server_reclamation_client_timeout_sec": 10, "rx_socket_buffer_size": 1000000, "tx_socket_buffer_size": 1000000, "stream_monitor_interval_secs": 2, "rtp_udp_port_range": "31000-31200", "udp_latency_ms": 200, "udp_drop_on_latency": false, "webrtc_latency_ms": 1000, "enable_frame_drop": true, "webrtc_max_birate": 10000, "webrtc_min_birate": 2000, "webrtc_start_birate": 4000, "webrtc_peer_conn_timeout_sec": 10, "enable_grpc": false, "grpc_server_port": "50051", "webrtc_in_audio_sender_max_bitrate": 128000, "webrtc_in_video_degradation_preference": "resolution", "webrtc_in_video_sender_max_framerate": 30, "remote_vst_address": "", "webrtc_port_range": { "min": 0, "max": 0 }, "enable_websocket_pingpong": false, "websocket_keep_alive_ms": 5000 }, "onvif": { "device_discovery_timeout_secs": 10, "onvif_request_timeout_secs": 10, "device_discovery_freq_secs": 5, "device_discovery_interfaces": [], "max_devices_supported": 8, "bitrate_kbps": 8000, "framerate": 30, "resolution": "1920x1080", "max_gov_length": 60 }, "data": { "storage_config_file": "/home/vst/vst_release/configs/vst_storage.json", "storage_threshold_percentage": 95, "storage_monitoring_frequency_secs": 2, "nv_streamer_directory_path": "/tmp/nv_streamer/videos", "nv_streamer_loop_playback": true, "nv_streamer_seekable": false, "nv_streamer_sync_file_count": 0, "nv_streamer_max_upload_file_size_MB": 10000, "nv_streamer_media_container_supported": [ "mp4", "mkv" ], "nv_streamer_metadata_container_supported": [ "json" ], "nv_streamer_rtsp_server_output_buffer_size_kb": 1000, "supported_video_codecs": [ "h264", "h265" ], "supported_audio_codecs": [ "pcmu", "pcma", "mpeg4-generic" ], "enable_aging_policy": false, "max_video_download_size_MB": 1000, "always_recording": false, "event_recording": false, "event_record_length_secs": 10, "record_buffer_length_secs": 2, "use_software_path": true, "use_webrtc_inbuilt_encoder": "", "webrtc_in_fixed_resolution": "1280x720", "webrtc_in_max_framerate": 30, "webrtc_in_video_bitrate_thresold_percentage": 50, "webrtc_in_passthrough": false, "webrtc_sender_quality": "pass_through", "enable_rtsp_server_sei_metadata": false, "enable_proxy_server_sei_metadata": false, "gpu_indices": [], "webrtc_out_enable_insert_sps_pps": true, "webrtc_out_set_iframe_interval": 30, "webrtc_out_set_idr_interval": 30, "webrtc_out_min_drc_interval": 5, "device_name": "VST", "device_location": "", "enable_dec_low_latency_mode": true, "enable_avsync_udp_input": true, "use_standalone_udp_input": false, "enable_silent_audio_in_udp_input": false, "enable_udp_input_dump": false }, "notifications": { "enable_notification": false, "use_message_broker": "kafka", "message_broker_topic": "vst.event", "redis_server_env_var": "REDIS_SVC_SERVICE_HOST:6379", "kafka_server_address": "localhost:9092" }, "debug": { "enable_perf_logging": true, "enable_qos_monitoring": true, "qos_logfile_path": "./webroot/log/", "qos_data_capture_interval_sec": 1, "qos_data_publish_interval_sec": 5, "enable_gst_debug_probes": true, "enable_prometheus": false, "prometheus_port": "8080", "enable_highlighting_logs": true, "enable_debug_apis": true, "dump_webrtc_input_stats": false, "enable_frameid_in_webrtc_stream": false, "enable_network_bandwidth_notification": false, "enable_latency_logging": false }, "overlay": { "video_metadata_server": "localhost:9200/mdx-raw*", "video_metadata_query_batch_size_num_frames": 300, "use_video_metadata_protobuf": false, "enable_gem_drawing": true, "analytic_server_address": "", "overlay_text_font_type": "DejaVuSansMono.ttf" }, "security": { "use_https": false, "use_rtsp_authentication": false, "use_http_digest_authentication": false, "use_multi_user": false, "enable_user_cleanup": false, "session_max_age_sec": 2592000, "multi_user_extra_options": [ "Secure", "SameSite=none" ], "nv_org_id": "", "nv_ngc_key": "" }, "observability": { "enable_telemetry": false, "otlp_endpoint": "http://localhost:4318/v1/traces" } } ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/nvstreamer/configs/vst-storage.json ================================================ { "data_path": "./vst_data/", "video_path": "./vst_video/", "total_video_storage_size_MB": 100000 } ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/sdr/docker_cluster_config.json ================================================ { "perception-2d": { "provisioning_address": "localhost:9000", "process_type": "docker" } } ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/vss-search-analytics/configs/vss-search-analytics-kafka-config.json ================================================ { "kafka": { "brokers": "localhost:9092", "group": "mdx-fusion-search-analytics-app", "consumer": { "autoOffsetReset": "latest", "enableAutoCommit": false, "maxPollIntervalMs": 900000, "maxPartitionFetchBytes": 10485760, "fetchMaxBytes": 104857600, "maxPollRecords": 10000, "timeout": 0.01 }, "producer": { "lingerMs": 0 }, "topics": [ { "name": "raw", "value": "mdx-raw" }, { "name": "behavior", "value": "mdx-behavior" }, { "name": "embed", "value": "mdx-embed" }, { "name": "embedFiltered", "value": "mdx-embed-filtered" }, { "name": "notification", "value": "mdx-notification" } ] }, "sensors": [], "app": [ { "name": "behaviorWatermarkSec", "value": "30" }, { "name": "behaviorStateTimeout", "value": "10" }, { "name": "behaviorMaxPoints", "value": "100" }, { "name": "objectConfidenceThreshold", "value": "0.5" }, { "name": "coordinateSystem", "value": "euclidean" }, { "name": "embedEnableDownsampling", "value": "false" }, { "name": "embedDownsamplerType", "value": "window" }, { "name": "embedSensorTTLSec", "value": "3600" }, { "name": "embedDownsampleToleranceMode", "value": "cosine" }, { "name": "embedDownsampleSimilarityThreshold", "value": "0.90" }, { "name": "embedDownsampleMaxIntervalSec", "value": "300" }, { "name": "embedDownsampleWindowSize", "value": "60" }, { "name": "embedDownsampleMinNeighbours", "value": "3" }, { "name": "sourceType", "value": "kafka" }, { "name": "sinkType", "value": "kafka" }, { "name": "numWorkersForBehaviorCreation", "value": "2" }, { "name": "numWorkersForEmbedFiltering", "value": "1" }, { "name": "inSimulationMode", "value": "true" }, { "name": "stateManagementFilter", "value": "[\"Person\"]" } ] } ================================================ FILE: deployments/developer-workflow/dev-profile-search/video-analytics-2d-app/vss-search-analytics/configs/vss-search-analytics-redis-config.json ================================================ { "redis": { "host": "localhost", "port": 6379, "group": "mdx-fusion-search-analytics-app", "consumer": { "readCount": 200, "readBlockMs": 100 }, "producer": { "maxLen": 10000 }, "streams": [ { "name": "raw", "value": "mdx-raw" }, { "name": "behavior", "value": "mdx-behavior" }, { "name": "embed", "value": "mdx-embed" }, { "name": "embedFiltered", "value": "mdx-embed-filtered" }, { "name": "notification", "value": "mdx-notification" } ] }, "sensors": [], "app": [ { "name": "behaviorWatermarkSec", "value": "30" }, { "name": "behaviorStateTimeout", "value": "10" }, { "name": "behaviorMaxPoints", "value": "200" }, { "name": "objectConfidenceThreshold", "value": "0.5" }, { "name": "coordinateSystem", "value": "euclidean" }, { "name": "embedEnableDownsampling", "value": "false" }, { "name": "embedDownsamplerType", "value": "window" }, { "name": "embedSensorTTLSec", "value": "3600" }, { "name": "embedDownsampleToleranceMode", "value": "cosine" }, { "name": "embedDownsampleSimilarityThreshold", "value": "0.90" }, { "name": "embedDownsampleMaxIntervalSec", "value": "300" }, { "name": "embedDownsampleWindowSize", "value": "60" }, { "name": "embedDownsampleMinNeighbours", "value": "3" }, { "name": "sourceType", "value": "redis" }, { "name": "sinkType", "value": "redis" }, { "name": "numWorkersForBehaviorCreation", "value": "1" }, { "name": "numWorkersForEmbedFiltering", "value": "1" }, { "name": "inSimulationMode", "value": "true" }, { "name": "stateManagementFilter", "value": "[\"Person\"]" } ] } ================================================ FILE: deployments/developer-workflow/dev-profile-search/vss-agent/configs/config.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. general: use_uvloop: false front_end: _type: fastapi object_store: ${VSS_AGENT_OBJECT_STORE_TYPE} # Set to local_object_store or remote_object_store # Use custom FastAPI worker to enable streaming video ingest endpoint runner_class: vss_agents.api.custom_fastapi_worker.CustomFastApiFrontEndWorker # Configuration for streaming video ingest endpoint streaming_ingest: vst_internal_url: ${VST_INTERNAL_URL} rtvi_embed_base_url: http://${HOST_IP}:${RTVI_EMBED_PORT} rtvi_embed_model: cosmos-embed1-448p rtvi_embed_chunk_duration: 5 rtvi_cv_base_url: http://${HOST_IP}:${RTVI_CV_PORT} vlm_mode: ${VLM_MODE} internal_ip: ${HOST_IP} external_ip: ${EXTERNAL_IP} elasticsearch_url: ${ELASTIC_SEARCH_ENDPOINT} rtvi_embed_es_index: ${ELASTIC_SEARCH_INDEX} stream_mode: ${STREAM_MODE} # 'search' for search profile, 'other' for VST only endpoints: - path: /api/v1/videos method: POST description: Generate VST upload URL function_name: video_upload_url - path: /api/v1/search method: POST description: "Search for a video by query" function_name: search - path: /api/v1/attribute_search method: POST description: "Search for objects by visual attributes" function_name: attribute_search - path: /api/v1/embed_search method: POST description: "Direct embedding search (bypasses agent)" function_name: embed_search - path: /api/v1/critic method: POST description: "Score video clips with critic agent" function_name: critic_agent cors: allow_origins: ['*'] allow_methods: ['*'] allow_headers: ['*'] allow_credentials: false telemetry: tracing: phoenix: _type: phoenix endpoint: ${PHOENIX_ENDPOINT}/v1/traces project: DEV-SEARCH-vss-agent-${VSS_AGENT_VERSION} # Uncomment the following to enable Weave experiment tracking: # weave: # _type: weave # project: ${WEAVE_PROJECT} object_stores: local_object_store: _type: in_memory functions: video_upload_url: _type: video_upload_url vst_external_url: ${VST_EXTERNAL_URL} agent_base_url: ${VSS_AGENT_EXTERNAL_URL} search: _type: search embed_search_tool: embed_search attribute_search_tool: attribute_search # Optional: enable object-level search agent_mode_llm: ${LLM_MODEL_TYPE:-nim}_llm use_attribute_search: true # Internal config: enable fusion reranking with attribute search critic_agent: critic_agent # Optional: enables VLM verification enable_critic: false search_max_iterations: 1 default_max_results: 10 vst_internal_url: ${VST_INTERNAL_URL} # For stream_id to sensor_id conversion in fusion reranking embed_confidence_threshold: 0.1 # Minimum embed score threshold. fallback to attribute-only search if low search_agent: _type: search_agent embed_search_tool: embed_search attribute_search_tool: attribute_search agent_mode_llm: ${LLM_MODEL_TYPE:-nim}_llm use_attribute_search: true vst_internal_url: ${VST_INTERNAL_URL} # For stream_id to sensor_id conversion in fusion reranking embed_confidence_threshold: 0.1 # Minimum embed score threshold. fallback to attribute-only search if low critic_agent: critic_agent # Optional: enables VLM verification enable_critic: false search_max_iterations: 1 default_max_results: 10 vst_video_clip: _type: vst.video_clip vst_internal_url: ${VST_INTERNAL_URL} vst_external_url: ${VST_EXTERNAL_URL} time_format: offset embed_search: _type: embed_search cosmos_embed_endpoint: ${COSMOS_EMBED_ENDPOINT} es_endpoint: ${ELASTIC_SEARCH_ENDPOINT} # source_type="video_file" searches this, source_type="rtsp" searches all except this es_index: ${ELASTIC_SEARCH_INDEX} vst_external_url: ${VST_EXTERNAL_URL} vst_internal_url: ${VST_INTERNAL_URL} default_max_results: 100 attribute_search: _type: attribute_search rtvi_cv_endpoint: http://${HOST_IP}:${RTVI_CV_PORT} es_endpoint: ${ELASTIC_SEARCH_ENDPOINT} # Use same ES as embed_search # source_type="video_file" searches this, source_type="rtsp" searches all except this) behavior_index: mdx-behavior-2025-01-01 # Corresponding frames index for uploaded video files (source_type="rtsp" searches all except this frames_index: mdx-raw-2025-01-01 enable_frame_lookup: false vst_external_url: ${VST_EXTERNAL_URL} vst_internal_url: ${VST_INTERNAL_URL} critic_agent: _type: critic_agent max_concurrent_verifications: 5 video_analysis_tool: video_understanding time_format: offset video_understanding: _type: video_understanding vlm_name: ${VLM_MODEL_TYPE:-nim}_vlm max_frames: 60 max_fps: 2 # for CR1, max fps is 2 min_pixels: 3136 max_pixels: 12845056 reasoning: false video_url_tool: vst_video_clip time_format: offset vlm_mode: ${VLM_MODE} internal_ip: ${HOST_IP} external_ip: ${EXTERNAL_IP} vst_internal_url: ${VST_INTERNAL_URL} llms: # --- LLM profiles (selected by LLM_MODEL_TYPE) --- nim_llm: _type: nim model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 openai_llm: _type: openai model_name: ${LLM_NAME} base_url: ${LLM_BASE_URL}/v1 max_tokens: 4096 temperature: 0.0 # --- VLM profiles (selected by VLM_MODEL_TYPE) --- nim_vlm: _type: nim model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 openai_vlm: _type: openai model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 vllm_vlm: _type: openai model_name: ${VLM_NAME} base_url: ${VLM_BASE_URL}/v1 temperature: 0.0 max_tokens: 4096 workflow: _type: top_agent llm_name: ${LLM_MODEL_TYPE:-nim}_llm llm_reasoning: false max_iterations: 20 max_history: 0 subagent_names: # Use search_agent as a sub-agent for streaming output - search_agent prompt: | You are a video search assistant. Use the search_agent to find videos matching user queries. The search_agent will decompose queries, run embed search, and perform fusion reranking. When a user asks to search for videos, call the search_agent with: - query: The user's search query - agent_mode: true (to enable query decomposition) - use_attribute_search: true (to enable fusion reranking) - max_results: Number of results to return (default: 5) ================================================ FILE: deployments/foundational/3rdParty_Licenses ================================================ jq jq is copyright (C) 2012 Stephen Dolan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. jq's documentation (everything found under the docs/ subdirectory in the source tree) is licensed under the Creative Commons CC BY 3.0 license, which can be found at: https://creativecommons.org/licenses/by/3.0/ The documentation website includes a copy of Twitter's Bootstrap and relies on Bonsai, Liquid templates and various other projects, look them up for detailed licensing conditions. jq incorporates David M. Gay's dtoa.c and g_fmt.c, which bear the following notices: dtoa.c: The author of this software is David M. Gay. Copyright (c) 1991, 2000, 2001 by Lucent Technologies. Permission to use, copy, modify, and distribute this software for any purpose without fee is hereby granted, provided that this entire notice is included in all copies of any software which is or includes a copy or modification of this software and in all copies of the supporting documentation for such software. THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED WARRANTY. IN PARTICULAR, NEITHER THE AUTHOR NOR LUCENT MAKES ANY REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. g_fmt.c: The author of this software is David M. Gay. Copyright (c) 1991, 1996 by Lucent Technologies. Permission to use, copy, modify, and distribute this software for any purpose without fee is hereby granted, provided that this entire notice is included in all copies of any software which is or includes a copy or modification of this software and in all copies of the supporting documentation for such software. THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED WARRANTY. IN PARTICULAR, NEITHER THE AUTHOR NOR LUCENT MAKES ANY REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. jq uses parts of the open source C library "decNumber", which is distributed under the following license: ICU License - ICU 1.8.1 and later COPYRIGHT AND PERMISSION NOTICE Copyright (c) 1995-2005 International Business Machines Corporation and others All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, provided that the above copyright notice(s) and this permission notice appear in all copies of the Software and that both the above copyright notice(s) and this permission notice appear in supporting documentation. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. Except as contained in this notice, the name of a copyright holder shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Software without prior written authorization of the copyright holder. Portions Copyright (c) 2016 Kungliga Tekniska Högskolan (Royal Institute of Technology, Stockholm, Sweden). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: deployments/foundational/Dockerfiles/elastic-init.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM alpine:3.23.2 # Create a working directory WORKDIR /opt/mdx/ # Copy the init scripts into the working directory COPY ./elk/init-scripts ./init-scripts # Make scripts executable RUN chmod +x ./init-scripts/*.sh # Install bash and curl commands. RUN apk update && apk add bash RUN apk --no-cache add curl ================================================ FILE: deployments/foundational/Dockerfiles/elasticsearch-gpu.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # ES_VERSION - Elasticsearch image tag (9.3.0) # CUDA_VERSION - CUDA runtime version (12.9.0) # CUVS_VERSION - Elastic cuVS tarball version (25.12.0) # FROM nvidia/cuda:12.9.0-cudnn-runtime-ubuntu22.04 AS cuda12libs ARG CUVS_VERSION=25.12.0 RUN apt-get update && apt-get install -y --no-install-recommends --allow-change-held-packages \ libnccl2 curl tar gzip libgomp1 \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p /out/cuvs && cd /out/cuvs \ && curl -fLO "https://storage.googleapis.com/elasticsearch-cuvs-snapshots/libcuvs/libcuvs-${CUVS_VERSION}.tar.gz" \ && tar -xzf "libcuvs-${CUVS_VERSION}.tar.gz" && rm -f "libcuvs-${CUVS_VERSION}.tar.gz" \ && if [ -d "${CUVS_VERSION}" ]; then mv "${CUVS_VERSION}"/* .; rmdir "${CUVS_VERSION}" 2>/dev/null || true; fi \ && cp -P /usr/lib/x86_64-linux-gnu/libgomp.so* /out/cuvs/ FROM docker.elastic.co/elasticsearch/elasticsearch:9.3.0 ENV ES_HOME=/usr/share/elasticsearch ENV LIBCUVS_DIR=/opt/cuvs ENV CUDA12_LIBS=/opt/cuda12-libs ENV LD_LIBRARY_PATH=${LIBCUVS_DIR}:${CUDA12_LIBS}:${LD_LIBRARY_PATH} ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility ENV ES_SETTING_VECTORS_INDEXING_USE__GPU=true COPY --from=cuda12libs /usr/local/cuda/lib64/ "${CUDA12_LIBS}/" COPY --from=cuda12libs /usr/lib/x86_64-linux-gnu/libnccl*.so* "${CUDA12_LIBS}/" COPY --from=cuda12libs /out/cuvs/ "${LIBCUVS_DIR}/" USER root RUN chown -R 1000:1000 "${ES_HOME}" "${LIBCUVS_DIR}" "${CUDA12_LIBS}" USER 1000:1000 WORKDIR ${ES_HOME} EXPOSE 9200 9300 ENTRYPOINT ["/usr/share/elasticsearch/bin/elasticsearch"] ================================================ FILE: deployments/foundational/Dockerfiles/elasticsearch.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM docker.elastic.co/elasticsearch/elasticsearch:9.3.0 ENV ES_HOME=/usr/share/elasticsearch USER 1000:1000 WORKDIR ${ES_HOME} EXPOSE 9200 9300 ENTRYPOINT ["/usr/share/elasticsearch/bin/elasticsearch"] ================================================ FILE: deployments/foundational/Dockerfiles/kafka-health-check.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Dockerfile specifically for Kafka health check # Uses Confluent Kafka image with all Kafka tools FROM confluentinc/cp-kafka:8.1.1 # Install jq in a user-writable location with architecture detection RUN mkdir -p /home/appuser/jqbin && \ ARCH=$(uname -m) && \ if [ "$ARCH" = "x86_64" ]; then \ JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"; \ elif [ "$ARCH" = "aarch64" ]; then \ JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-arm64"; \ else \ echo "Unsupported architecture: $ARCH" && exit 1; \ fi && \ curl -L -o /home/appuser/jqbin/jq "$JQ_URL" && \ chmod +x /home/appuser/jqbin/jq # Copy Kafka health check script COPY --chmod=755 ./broker-health-check/scripts/check-kafka-health.sh /scripts/check-kafka-health.sh USER appuser # Direct entrypoint to Kafka health check script ENTRYPOINT ["/scripts/check-kafka-health.sh"] ================================================ FILE: deployments/foundational/Dockerfiles/redis-health-check.Dockerfile ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Dockerfile specifically for Redis health check # Uses lightweight Alpine image FROM alpine:3.23.2 # Install necessary tools for port checking RUN apk add --no-cache \ bash \ netcat-openbsd # Copy Redis health check script COPY --chmod=755 ./broker-health-check/scripts/check-redis-health.sh /scripts/check-redis-health.sh # Direct entrypoint to Redis health check script ENTRYPOINT ["/scripts/check-redis-health.sh"] ================================================ FILE: deployments/foundational/broker-health-check/scripts/check-kafka-health.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -euo pipefail echo " Kafka health check service started..." # Configuration with defaults MAX_RETRIES=${MAX_RETRIES:-60} # Max retries for broker and topics RETRY_INTERVAL=${RETRY_INTERVAL:-2} # Seconds between retries KAFKA_HOST=${BOOTSTRAP_HOST:-localhost} KAFKA_PORT=${KAFKA_PORT:-9092} echo "Configuration:" echo " MAX_RETRIES: $MAX_RETRIES ($(($MAX_RETRIES * $RETRY_INTERVAL))s timeout)" echo " RETRY_INTERVAL: ${RETRY_INTERVAL}s" echo " KAFKA_HOST: $KAFKA_HOST" echo " KAFKA_PORT: $KAFKA_PORT" # Add jq to PATH if it exists if [ -f /home/appuser/jqbin/jq ]; then export PATH="/home/appuser/jqbin:${PATH}" fi # Function to parse topics/streams from JSON environment variable parse_topics_from_json() { local json_var="$1" if [ -n "$json_var" ]; then echo "$json_var" | jq -r '.[].name' fi } echo "Waiting for Kafka topics to be created..." # Parse Kafka topics from environment variable if [ -n "$KAFKA_TOPICS" ]; then echo "Parsing topics from KAFKA_TOPICS environment variable..." readarray -t REQUIRED_KAFKA_TOPICS < <(parse_topics_from_json "$KAFKA_TOPICS") echo "Found ${#REQUIRED_KAFKA_TOPICS[@]} topics to check" else echo "WARNING: No KAFKA_TOPICS environment variable found, skipping topic validation" REQUIRED_KAFKA_TOPICS=() fi # Wait for Kafka to be reachable first kafka_retry_count=0 echo "Waiting for Kafka at $KAFKA_HOST:$KAFKA_PORT (max ${MAX_RETRIES} retries)..." while [ $kafka_retry_count -lt $MAX_RETRIES ]; do if kafka-broker-api-versions --bootstrap-server $KAFKA_HOST:$KAFKA_PORT >/dev/null 2>&1; then echo "✓ Kafka broker is reachable after $kafka_retry_count retries" break fi kafka_retry_count=$((kafka_retry_count + 1)) echo "[$kafka_retry_count/$MAX_RETRIES] Waiting for Kafka to be ready..." sleep $RETRY_INTERVAL done if [ $kafka_retry_count -eq $MAX_RETRIES ]; then echo "❌ ERROR: Kafka broker at $KAFKA_HOST:$KAFKA_PORT is not reachable after $MAX_RETRIES retries with $RETRY_INTERVAL seconds interval" exit 1 fi # If we have topics to check, wait for them to exist if [ ${#REQUIRED_KAFKA_TOPICS[@]} -gt 0 ]; then echo "Checking for required Kafka topics: ${REQUIRED_KAFKA_TOPICS[*]}" topic_retry_count=0 while [ $topic_retry_count -lt $MAX_RETRIES ]; do missing_topics=() for topic in "${REQUIRED_KAFKA_TOPICS[@]}"; do if ! kafka-topics --bootstrap-server $KAFKA_HOST:$KAFKA_PORT --list 2>/dev/null | grep -q "^${topic}$"; then missing_topics+=("$topic") fi done if [ ${#missing_topics[@]} -eq 0 ]; then echo "✓ All required Kafka topics are present after $topic_retry_count retries" # List all topics for verification echo "Current Kafka topics:" kafka-topics --bootstrap-server $KAFKA_HOST:$KAFKA_PORT --list 2>/dev/null | while read topic; do echo " - $topic" done break else topic_retry_count=$((topic_retry_count + 1)) echo "[$topic_retry_count/$MAX_RETRIES] Waiting for missing topics: ${missing_topics[*]}" sleep $RETRY_INTERVAL fi done if [ $topic_retry_count -eq $MAX_RETRIES ]; then echo "❌ ERROR: Required Kafka topics not created after $MAX_RETRIES retries with $RETRY_INTERVAL seconds interval" echo "Missing topics: ${missing_topics[*]}" echo "" echo "Existing topics:" kafka-topics --bootstrap-server $KAFKA_HOST:$KAFKA_PORT --list 2>/dev/null | while read topic; do echo " - $topic" done exit 1 fi else echo "No topics to validate, listing existing topics:" kafka-topics --bootstrap-server $KAFKA_HOST:$KAFKA_PORT --list 2>/dev/null | while read topic; do echo " - $topic" done fi echo "✅ Kafka health check completed successfully" exit 0 ================================================ FILE: deployments/foundational/broker-health-check/scripts/check-redis-health.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -euo pipefail echo "Redis health check service started..." # Configuration with defaults MAX_RETRIES=${MAX_RETRIES:-60} # Max retries for Redis connection RETRY_INTERVAL=${RETRY_INTERVAL:-2} # Seconds between retries REDIS_HOST=${BOOTSTRAP_HOST:-localhost} REDIS_PORT=${REDIS_PORT:-6379} echo "Configuration:" echo " MAX_RETRIES: $MAX_RETRIES ($(($MAX_RETRIES * $RETRY_INTERVAL))s timeout)" echo " RETRY_INTERVAL: ${RETRY_INTERVAL}s" echo " REDIS_HOST: $REDIS_HOST" echo " REDIS_PORT: $REDIS_PORT" echo "Waiting for Redis to be ready..." # Wait for Redis to be reachable redis_retry_count=0 echo "Waiting for Redis at $REDIS_HOST:$REDIS_PORT (max ${MAX_RETRIES} retries)..." while [ $redis_retry_count -lt $MAX_RETRIES ]; do if nc -z $REDIS_HOST $REDIS_PORT 2>/dev/null; then echo "✓ Redis is reachable after $redis_retry_count retries" break fi redis_retry_count=$((redis_retry_count + 1)) echo "[$redis_retry_count/$MAX_RETRIES] Waiting for Redis..." sleep $RETRY_INTERVAL done if [ $redis_retry_count -eq $MAX_RETRIES ]; then echo "❌ ERROR: Redis at $REDIS_HOST:$REDIS_PORT is not reachable after $MAX_RETRIES retries with $RETRY_INTERVAL seconds interval" exit 1 fi echo "✅ Redis health check completed successfully" exit 0 ================================================ FILE: deployments/foundational/elk/configs/elasticsearch.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. #cluster.name: "docker-cluster" network.host: 0.0.0.0 http.port: 9200 #----------------------- BEGIN SECURITY AUTO CONFIGURATION ----------------------- # # The following settings, TLS certificates, and keys have been automatically # generated to configure Elasticsearch security features on 04-08-2025 10:18:32 # # -------------------------------------------------------------------------------- # Enable security features xpack.security.enabled: false # # Path to directory where to store the data (separate multiple locations by comma): path.data: /tmp/elastic/data/ # # Path to log files: # path.logs: /tmp/elastic/logs ================================================ FILE: deployments/foundational/elk/configs/kibana.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ** THIS IS AN AUTO-GENERATED FILE ** # # Default Kibana configuration for docker target server.host: "0.0.0.0" server.shutdownTimeout: "5s" elasticsearch.hosts: [ "http://localhost:9200" ] monitoring.ui.container.elasticsearch.enabled: true ================================================ FILE: deployments/foundational/elk/configs/logstash.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. pipeline.workers: 1 pipeline.ordered: true pipeline.ecs_compatibility: disabled xpack.monitoring.elasticsearch.hosts: ["http://localhost:9200"] api.http.host: "0.0.0.0" ================================================ FILE: deployments/foundational/elk/configs/mdx-kafka-logstash.conf ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. input { kafka { type => "mdx-raw" consumer_threads => 4 topics => ["mdx-raw"] decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Frame" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/schema_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-bev" consumer_threads => 4 topics_pattern => "^mdx-bev.*" decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Frame" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/schema_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-behavior" consumer_threads => 4 topics => ["mdx-behavior", "mdx-behavior-plus"] decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Behavior" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/ext_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-alerts" consumer_threads => 4 topics => ["mdx-alerts"] auto_offset_reset => "latest" decorate_events => true group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Behavior" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/ext_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-events" consumer_threads => 4 topics => ["mdx-events"] decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Behavior" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/ext_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-incidents" consumer_threads => 4 topics => ["mdx-incidents"] decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Incident" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/ext_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-vlm-incidents" consumer_threads => 4 topics => ["mdx-vlm-incidents"] decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Incident" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/ext_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-frames" consumer_threads => 4 topics => ["mdx-frames"] decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Frame" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/schema_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-mtmc" consumer_threads => 4 topics => ["mdx-mtmc"] decorate_events => true auto_offset_reset => "latest" group_id => "logstash" codec => "plain" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" bootstrap_servers => "localhost:9092" } kafka { type => "mdx-rtls" consumer_threads => 4 topics_pattern => "^mdx-rtls.*" decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Frame" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/schema_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-space-utilization" consumer_threads => 4 topics => ["mdx-space-utilization"] decorate_events => true auto_offset_reset => "latest" group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.SpaceUtilization" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/ext_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-amr" consumer_threads => 4 topics => ["mdx-amr"] decorate_events => "extended" auto_offset_reset => "latest" group_id => "logstash" codec => "plain" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" bootstrap_servers => "localhost:9092" } kafka { type => "mdx-vlm-alerts" consumer_threads => 4 topics => ["mdx-vlm-alerts"] auto_offset_reset => "latest" decorate_events => true group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.Behavior" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/ext_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } kafka { type => "mdx-embed-filtered" consumer_threads => 4 topics => ["mdx-embed-filtered"] auto_offset_reset => "latest" decorate_events => true group_id => "logstash" key_deserializer_class => "org.apache.kafka.common.serialization.StringDeserializer" value_deserializer_class => "org.apache.kafka.common.serialization.ByteArrayDeserializer" codec => protobuf { class_name => "nv.VisionLLM" class_file => '/opt/logstash-data-libs/logstash/pb_definitions/schema_pb.rb' protobuf_root_directory => "/opt/logstash-data-libs/logstash/pb_definitions/" protobuf_version => 3 } bootstrap_servers => "localhost:9092" } } filter { json { source => "message" } # Formatting timestamp ruby { code => "event.set('timestamp',(((event.get('[timestamp][seconds]').to_f)*1000) +((event.get('[timestamp][nanos]').to_f) * (10 ** -6)).floor()))" } date { match => [ "timestamp","UNIX_MS" ] target => "timestamp" timezone => "UTC" } if [type] == "mdx-behavior" or [type] == "mdx-events" or [type] == "mdx-alerts" or [type] == "mdx-vlm-alerts" or [type] == "mdx-incidents" or [type] == "mdx-vlm-incidents" or [type] == "mdx-embed-filtered" { # Formatting end timestamp ruby { code => "event.set('end',(((event.get('[end][seconds]').to_f)*1000) +((event.get('[end][nanos]').to_f) * (10 ** -6)).floor()))" } date { match => [ "end","UNIX_MS" ] target => "end" timezone => "UTC" } # Removing embeddings field # mutate { # remove_field => ["embeddings"] # } if "[object][bbox3d]" { mutate { remove_field => ["[object][bbox3d][embeddings]"] } } # Formatting locations and smoothLocations fields if [type] != "mdx-incidents" and [type] != "mdx-vlm-incidents" { ruby { code => ' locations = [] currentLocations=event.get("locations") if currentLocations for location in currentLocations["coordinates"] do locations.append(location["point"]) end event.set("[locations][coordinates]",locations) end smoothLocations = [] currentSmoothLocations=event.get("smoothLocations") if currentSmoothLocations for smoothLocation in currentSmoothLocations["coordinates"] do smoothLocations.append(smoothLocation["point"]) end event.set("[smoothLocations][coordinates]",smoothLocations) end ' } } } if [type] == "mdx-behavior" { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "[place][name]", "[sensor][id]", "[object][id]"] concatenate_sources => true target => "Id" } } else if [type] == "mdx-alerts" or [type] == "mdx-vlm-alerts" or [type] == "mdx-events" { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "[place][name]", "[sensor][id]", "[object][id]", "[analyticsModule][id]"] concatenate_sources => true target => "Id" } } else if [type] == "mdx-mtmc" { fingerprint { method => "SHA1" key => "HMAC" source => [ "globalId", "timestamp"] concatenate_sources => true target => "Id" } } else if [type] == "mdx-space-utilization" { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "id" ] concatenate_sources => true target => "Id" } } else if [type] == "mdx-incidents" or [type] == "mdx-vlm-incidents" { if [info] and [info][primaryObjectId] { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "category", "sensorId", "[info][primaryObjectId]" ] concatenate_sources => true target => "Id" } } else { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "category", "sensorId" ] concatenate_sources => true target => "Id" } } } else if [type] == "mdx-embed-filtered" { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "end", "[sensor][id]" ] concatenate_sources => true target => "Id" } } if [type] == "mdx-amr" { mutate { update => { "type" => "%{[@metadata][kafka][headers][type]}" } } } grok { match => ["timestamp", "%{YEAR:[@metadata][year]}-%{MONTHNUM:[@metadata][month]}-%{MONTHDAY:[@metadata][day]}T%{GREEDYDATA}"] } mutate { remove_field => ["kafka", "message", "@timestamp", "@version"] } } output { if [type] == "mdx-behavior" or [type] == "mdx-events" or [type] == "mdx-alerts" or [type] == "mdx-mtmc" or [type] == "mdx-space-utilization" or [type] == "mdx-vlm-alerts" or [type] == "mdx-incidents" or [type] == "mdx-vlm-incidents" or [type] == "mdx-embed-filtered" { elasticsearch { hosts => "localhost:9200" index => "%{type}-%{[@metadata][year]}-%{[@metadata][month]}-%{[@metadata][day]}" document_type => "logs" retry_max_interval => 10 action => "index" document_id => "%{Id}" timeout => 60 } } else { elasticsearch { hosts => "localhost:9200" index => "%{type}-%{[@metadata][year]}-%{[@metadata][month]}-%{[@metadata][day]}" document_type => "logs" retry_max_interval => 10 action => "index" timeout => 60 } } } ================================================ FILE: deployments/foundational/elk/configs/mdx-redis-logstash.conf ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. input { redis_stream { host => "localhost" port => 6379 stream_key => "mdx-raw" group => "logstash" type => "mdx-raw" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Frame" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/schema.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-bev" group => "logstash" type => "mdx-bev" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Frame" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/schema.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-frames" group => "logstash" type => "mdx-frames" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Frame" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/schema.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-behavior" group => "logstash" type => "mdx-behavior" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Behavior" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-behavior-plus" group => "logstash" type => "mdx-behavior" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Behavior" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-alerts" group => "logstash" type => "mdx-alerts" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Behavior" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-events" group => "logstash" type => "mdx-events" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Behavior" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-incidents" group => "logstash" type => "mdx-incidents" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Incident" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-vlm-incidents" group => "logstash" type => "mdx-vlm-incidents" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Incident" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-mtmc" group => "logstash" type => "mdx-mtmc" decorate_events => true data_field => "value" data_codec => { "type" => "plain" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-rtls-region-1" group => "logstash" type => "mdx-rtls" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Frame" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/schema.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-space-utilization" group => "logstash" type => "mdx-space-utilization" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.SpaceUtilization" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-amr" group => "logstash" type => "mdx-amr" decorate_events => true data_field => "value" data_codec => { "type" => "plain" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-vlm-alerts" group => "logstash" type => "mdx-vlm-alerts" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.Behavior" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc" } } redis_stream { host => "localhost" port => 6379 stream_key => "mdx-embed-filtered" group => "logstash" type => "mdx-embed-filtered" decorate_events => true data_field => "value" data_codec => { "type" => "protobuf" "class_name" => "nv.VisionLLM" "class_file" => "/opt/logstash-data-libs/logstash/pb_definitions/descriptors/schema.desc" } } } filter { # Parse JSON for plain codec inputs json { source => "message" } # Formatting timestamp date { match => [ "timestamp", "ISO8601" ] target => "timestamp" timezone => "UTC" } if [type] == "mdx-behavior" or [type] == "mdx-events" or [type] == "mdx-alerts" or [type] == "mdx-vlm-alerts" or [type] == "mdx-incidents" or [type] == "mdx-vlm-incidents" or [type] == "mdx-embed-filtered" { # Formatting end timestamp date { match => [ "end", "ISO8601" ] target => "end" timezone => "UTC" } # Removing embeddings field # mutate { # remove_field => ["embeddings"] # } if "[object][bbox3d]" { mutate { remove_field => ["[object][bbox3d][embeddings]"] } } # Formatting locations and smoothLocations fields if [type] != "mdx-incidents" and [type] != "mdx-vlm-incidents" { ruby { code => ' locations = [] currentLocations=event.get("locations") if currentLocations for location in currentLocations["coordinates"] do locations.append(location["point"]) end event.set("[locations][coordinates]",locations) end smoothLocations = [] currentSmoothLocations=event.get("smoothLocations") if currentSmoothLocations for smoothLocation in currentSmoothLocations["coordinates"] do smoothLocations.append(smoothLocation["point"]) end event.set("[smoothLocations][coordinates]",smoothLocations) end ' } } } if [type] == "mdx-behavior" { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "[place][name]", "[sensor][id]", "[object][id]"] concatenate_sources => true target => "Id" } } else if [type] == "mdx-alerts" or [type] == "mdx-vlm-alerts" or [type] == "mdx-events" { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "[place][name]", "[sensor][id]", "[object][id]", "[analyticsModule][id]"] concatenate_sources => true target => "Id" } } else if [type] == "mdx-mtmc" { fingerprint { method => "SHA1" key => "HMAC" source => [ "globalId", "timestamp"] concatenate_sources => true target => "Id" } } else if [type] == "mdx-space-utilization" { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "id" ] concatenate_sources => true target => "Id" } } else if [type] == "mdx-incidents" or [type] == "mdx-vlm-incidents" { if [info] and [info][primaryObjectId] { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "category", "sensorId", "[info][primaryObjectId]" ] concatenate_sources => true target => "Id" } } else { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "category", "sensorId" ] concatenate_sources => true target => "Id" } } } else if [type] == "mdx-embed-filtered" { fingerprint { method => "SHA1" key => "HMAC" source => [ "timestamp", "end", "[sensor][id]" ] concatenate_sources => true target => "Id" } } if [type] == "mdx-amr" { mutate { update => { "type" => "%{[headers][type]}" } } } grok { match => ["timestamp", "%{YEAR:[@metadata][year]}-%{MONTHNUM:[@metadata][month]}-%{MONTHDAY:[@metadata][day]}T%{GREEDYDATA}"] } mutate { remove_field => ["redis_stream_id", "redis_stream_key", "value", "@timestamp", "@version", "key", "headers"] } } output { if [type] == "mdx-behavior" or [type] == "mdx-events" or [type] == "mdx-alerts" or [type] == "mdx-mtmc" or [type] == "mdx-space-utilization" or [type] == "mdx-vlm-alerts" or [type] == "mdx-incidents" or [type] == "mdx-vlm-incidents" or [type] == "mdx-embed-filtered" { elasticsearch { hosts => "localhost:9200" index => "%{type}-%{[@metadata][year]}-%{[@metadata][month]}-%{[@metadata][day]}" document_type => "logs" retry_max_interval => 10 action => "index" document_id => "%{Id}" timeout => 60 } } else { elasticsearch { hosts => "localhost:9200" index => "%{type}-%{[@metadata][year]}-%{[@metadata][month]}-%{[@metadata][day]}" document_type => "logs" retry_max_interval => 10 action => "index" timeout => 60 } } } ================================================ FILE: deployments/foundational/elk/gems/logstash-input-redis_stream-3.1.0-java.gem ================================================ version https://git-lfs.github.com/spec/v1 oid sha256:ef29e5c3056057a872c78369a7a672f48d9fe1434e8eff654d3ad0b68ba85183 size 9771520 ================================================ FILE: deployments/foundational/elk/init-scripts/elasticsearch-ilm-policy-creation.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -euo pipefail # ELASTICSEARCH CONNECTION VARIABLES (parameterized from docker compose) ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS=0 ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS="${ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS:-20}" ELASTICSEARCH_URL="http://localhost:9200" # ILM policy retention period (default: 4h) ELASTICSEARCH_ILM_MIN_AGE="${ELASTICSEARCH_ILM_MIN_AGE:-4h}" ################################# ## function: check_ES_status ################################# check_ES_status(){ echo "Attempting to connect to the Elasticsearch server for ILM policy creation." # Wait for ES to come up until curl --output /dev/null --silent --head --fail -XGET "$ELASTICSEARCH_URL"; do if [ ${ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS} -eq ${ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to ES reached." fi ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS=$(($ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to ES. Trying to reconnect - (attempt $ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS/$ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS)" sleep 5 done } configure_ilm_settings(){ echo "Configuring ILM settings for faster execution." # Set ILM poll interval to 30 seconds instead of default 10 minutes curl -X PUT "$ELASTICSEARCH_URL/_cluster/settings" \ -H 'Content-Type: application/json' \ --data-raw '{ "persistent": { "indices.lifecycle.poll_interval": "30s" } }' \ --compressed \ --insecure || exit_with_msg "Failed to configure ILM poll interval." echo "ILM poll interval set to 30 seconds." } #################################### ## function: create_ilm_policies #################################### create_ilm_policy() { local policy_name="$1" local policy_config="$2" echo "Creating ILM policy: ${policy_name}" response=$(curl -s -w "\\n%{http_code}" "${ELASTICSEARCH_URL}/_ilm/policy/${policy_name}" \ -X 'PUT' \ -H 'Content-Type: application/json' \ --data-raw "${policy_config}" \ --compressed \ --insecure) http_code=$(echo "$response" | tail -n1) response_body=$(echo "$response" | sed '$d') echo "HTTP code: ${http_code}" if [ "${http_code}" -ne 200 ]; then echo "Error response from Elasticsearch:" >&2 echo "${response_body}" >&2 exit_with_msg "Curl command to create ${policy_name} in Elasticsearch failed with HTTP status ${http_code}." fi echo "Successfully created ${policy_name}." } create_ilm_policies(){ echo "Creating ILM policies for indices." # Create all ILM policies using the configured min_age create_ilm_policy 'mdx-behavior-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-raw-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-frames-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-alerts-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-events-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-mtmc-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-rtls-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-amr-locations-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-amr-events-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-bev-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-space-utilization-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-vlm-alerts-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-incidents-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-vlm-incidents-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" create_ilm_policy 'mdx-embed-filtered-ilm-policy' "{\"policy\":{\"phases\":{\"delete\":{\"min_age\":\"${ELASTICSEARCH_ILM_MIN_AGE}\",\"actions\":{\"delete\":{}}}}}}" echo "All ILM policies created successfully." } ############################ ## function: exit_with_msg ############################ exit_with_msg(){ echo -e "$1 \nExiting Script." exit 1 } ###################### ## Main ###################### main(){ check_ES_status configure_ilm_settings create_ilm_policies } main ================================================ FILE: deployments/foundational/elk/init-scripts/elasticsearch-ingest-pipeline-creation.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -euo pipefail # ELASTICSEARCH CONNECTION VARIABLES (parameterized from docker compose) ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS="${ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS:-0}" ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS="${ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS:-20}" ELASTICSEARCH_URL="${ELASTICSEARCH_URL:-http://localhost:9200}" ################################# ## function: check_ES_status ################################# check_ES_status(){ echo "Attempting to connect to the Elasticsearch server for ingest pipeline creation." until curl --output /dev/null --silent --head --fail -XGET "$ELASTICSEARCH_URL"; do if [ ${ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS} -eq ${ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to ES reached." fi ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS=$(($ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to ES. Trying to reconnect - (attempt $ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS/$ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS)" sleep 5 done } #################################### ## function: create_ingest_pipeline #################################### create_ingest_pipeline() { local pipeline_id="$1" local pipeline_config="$2" echo "Creating ingest pipeline: ${pipeline_id}" response=$(curl -s -w "\\n%{http_code}" "${ELASTICSEARCH_URL}/_ingest/pipeline/${pipeline_id}" \ -X 'PUT' \ -H 'Content-Type: application/json' \ --data-raw "${pipeline_config}" \ --compressed \ --insecure) http_code=$(echo "$response" | tail -n1) response_body=$(echo "$response" | sed '$d') echo "HTTP code: ${http_code}" if [ "${http_code}" -ne 200 ] && [ "${http_code}" -ne 201 ]; then echo "Error response from Elasticsearch:" >&2 echo "${response_body}" >&2 exit_with_msg "Curl command to create ${pipeline_id} in Elasticsearch failed with HTTP status ${http_code}." fi echo "Successfully created ${pipeline_id}." } #################################### ## function: create_insertion_timestamp_ingest_pipeline #################################### create_insertion_timestamp_ingest_pipeline() { local pipeline_id="insertion-timestamp-pipeline" local pipeline_config=$(cat <<'EOF' { "description": "Adds dynamic timestamp field to documents based on targetFieldName in document body", "processors": [ { "set": { "field": "_ingest_timestamp", "value": "{{_ingest.timestamp}}" } }, { "date": { "field": "_ingest_timestamp", "target_field": "_ingest_timestamp", "timezone": "UTC", "formats" : ["ISO8601"] } }, { "script": { "lang": "painless", "source": "ctx[ctx.targetFieldName] = ctx._ingest_timestamp;" } }, { "remove": { "field": "_ingest_timestamp", "ignore_missing": true } }, { "remove": { "field": "targetFieldName", "ignore_missing": true } } ] } EOF ) create_ingest_pipeline "${pipeline_id}" "${pipeline_config}" } ############################ ## function: exit_with_msg ############################ exit_with_msg(){ echo -e "$1 \nExiting Script." exit 1 } ###################### ## Main ###################### main(){ check_ES_status create_insertion_timestamp_ingest_pipeline } main "$@" ================================================ FILE: deployments/foundational/elk/init-scripts/elasticsearch-template-creation.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -euo pipefail # ELASTICSEARCH CONNECTION VARIABLES (parameterized from docker compose) ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS="${ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS:-0}" ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS="${ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS:-20}" ELASTICSEARCH_URL="${ELASTICSEARCH_URL:-http://localhost:9200}" BP_PROFILE=${BP_PROFILE:-} echo "BP_PROFILE: ${BP_PROFILE}" # Embedding dimensions for Elasticsearch dense_vector ELASTICSEARCH_RTVI_CV_EMBEDDINGS_DIM=${ELASTICSEARCH_RTVI_CV_EMBEDDINGS_DIM:-1536} ELASTICSEARCH_VISION_LLM_EMBEDDINGS_DIM=${ELASTICSEARCH_VISION_LLM_EMBEDDINGS_DIM:-768} ################################# ## function: check_ES_status ################################# check_ES_status(){ echo "Attempting to connect to the Elasticsearch server." # Wait for ES to come up until curl --output /dev/null --silent --head --fail -XGET "$ELASTICSEARCH_URL"; do if [ ${ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS} -eq ${ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS} ];then exit_with_msg "Max attempts to connect to ES reached." fi ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS=$(($ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS+1)) echo "Unable to connect to ES. Trying to reconnect - (attempt $ELASTICSEARCH_CONNECTION_RETRY_ATTEMPTS/$ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS)" sleep 5 done } #################################### ## function: create_index_template #################################### create_index_template(){ local template_name=$1 local data_raw=$2 echo "Creating index template: ${template_name}" response=$(curl -s -w "\\n%{http_code}" "${ELASTICSEARCH_URL}/_index_template/${template_name}" \ -X 'PUT' \ -H 'Content-Type: application/json' \ --data-raw "$data_raw" \ --compressed \ --insecure) curl_exit_code=$? if [ $curl_exit_code -ne 0 ]; then exit_with_msg "Curl command failed with exit code ${curl_exit_code} for template '${template_name}'. Error: ${response}" fi http_code=$(echo "$response" | tail -n1) echo "HTTP code: ${http_code}" if [ "$http_code" != "200" ]; then response_body=$(echo "$response"| sed '$d') exit_with_msg "Failed to create index template '${template_name}'.\n Status code: ${http_code}\n Response: ${response_body}" fi echo "Successfully created index template: ${template_name}" } #################################### ## function: setup_elasticsearch_templates #################################### setup_elasticsearch_templates(){ echo "Creating index templates." # metropolis_template - General settings for all mdx-* indices create_index_template "metropolis_template" '{ "index_patterns": ["mdx-*"], "priority": 100, "template": { "settings": { "number_of_shards": 16, "translog.durability": "async", "refresh_interval": "2s" } } }' create_index_template "mdx_alerts_template" '{ "index_patterns": ["mdx-alerts-*"], "priority": 501, "template": { "settings": { "index.lifecycle.name": "mdx-alerts-ilm-policy" }, "mappings": { "properties": { "locations": { "type": "geo_shape" }, "smoothLocations": { "type": "geo_shape" }, "speedOverTime": { "enabled": false }, "lipActivities": { "enabled": false }, "gazes": { "enabled": false }, "poses": { "enabled": false }, "object": { "properties": { "bbox": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } } } } } }' if [[ "${BP_PROFILE:-}" == "bp_developer_search" ]]; then create_index_template "mdx_behavior_template" '{ "index_patterns": ["mdx-behavior-*"], "priority": 502, "template": { "settings": { "index.lifecycle.name": "mdx-behavior-ilm-policy", "index.mapping.exclude_source_vectors": false }, "mappings": { "properties": { "locations": { "type": "geo_shape" }, "smoothLocations": { "type": "geo_shape" }, "speedOverTime": { "enabled": false }, "lipActivities": { "enabled": false }, "gazes": { "enabled": false }, "poses": { "enabled": false }, "embeddings": { "type": "nested", "properties": { "vector": { "type": "dense_vector", "dims": '"${ELASTICSEARCH_RTVI_CV_EMBEDDINGS_DIM}"', "index": true } } }, "object": { "properties": { "bbox": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } } } } } }' echo "Successfully created index template: mdx_behavior_template for bp_developer_search" else create_index_template "mdx_behavior_template" '{ "index_patterns": ["mdx-behavior-*"], "priority": 502, "template": { "settings": { "index.lifecycle.name": "mdx-behavior-ilm-policy" }, "mappings": { "properties": { "locations": { "type": "geo_shape" }, "smoothLocations": { "type": "geo_shape" }, "speedOverTime": { "enabled": false }, "lipActivities": { "enabled": false }, "gazes": { "enabled": false }, "poses": { "enabled": false }, "object": { "properties": { "bbox": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } } } } } }' fi create_index_template "mdx_events_template" '{ "index_patterns": ["mdx-events-*"], "priority": 503, "template": { "settings": { "index.lifecycle.name": "mdx-events-ilm-policy" }, "mappings": { "properties": { "locations": { "type": "geo_shape" }, "smoothLocations": { "type": "geo_shape" }, "speedOverTime": { "enabled": false }, "lipActivities": { "enabled": false }, "gazes": { "enabled": false }, "poses": { "enabled": false }, "object": { "properties": { "bbox": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } } } } } }' create_index_template "mdx_vlm_alerts_template" '{ "index_patterns": ["mdx-vlm-alerts-*"], "priority": 504, "template": { "settings": { "index.lifecycle.name": "mdx-vlm-alerts-ilm-policy" }, "mappings": { "properties": { "locations": { "type": "geo_shape" }, "smoothLocations": { "type": "geo_shape" }, "speedOverTime": { "enabled": false }, "lipActivities": { "enabled": false }, "gazes": { "enabled": false }, "poses": { "enabled": false }, "object": { "properties": { "bbox": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } } } } } }' create_index_template "mdx_frames_template" '{ "index_patterns": ["mdx-frames-*"], "priority": 505, "template": { "settings": { "index.lifecycle.name": "mdx-frames-ilm-policy" }, "mappings": { "properties": { "objects": { "type": "nested", "properties": { "bbox": { "enabled": false }, "bbox3d": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } }, "rois": { "type": "nested", "properties": { "coordinates": { "enabled": false } } }, "fov": { "type": "nested" }, "socialDistancing": { "properties": { "clusters": { "enabled": false } } } } } } }' create_index_template "mdx_mtmc_template" '{ "index_patterns": ["mdx-mtmc-*"], "priority": 506, "template": { "settings": { "index.lifecycle.name": "mdx-mtmc-ilm-policy" }, "mappings": { "properties": { "matched": { "type": "nested" } } } } }' create_index_template "mdx_rtls_template" '{ "index_patterns": ["mdx-rtls-*"], "priority": 507, "template": { "settings": { "index.lifecycle.name": "mdx-rtls-ilm-policy" }, "mappings": { "properties": { "objects": { "type": "nested", "properties": { "bbox": { "enabled": false }, "bbox3d": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } }, "rois": { "type": "nested", "properties": { "coordinates": { "enabled": false } } }, "fov": { "type": "nested" }, "socialDistancing": { "properties": { "clusters": { "enabled": false } } } } } } }' create_index_template "mdx_amr_locations_template" '{ "index_patterns": ["mdx-amr-locations-*"], "priority": 508, "template": { "settings": { "index.lifecycle.name": "mdx-amr-locations-ilm-policy" }, "mappings": { "properties": { "objectCounts": { "type": "nested" }, "locationsOfObjects": { "enabled": false } } } } }' create_index_template "mdx_amr_events_template" '{ "index_patterns": ["mdx-amr-events-*"], "priority": 509, "template": { "settings": { "index.lifecycle.name": "mdx-amr-events-ilm-policy" }, "mappings": { "properties": { "events": { "type": "nested", "properties": { "blockages": { "enabled": false }, "currentRoute": { "enabled": false }, "newRoute": { "enabled": false }, "currentLocation": { "enabled": false } } } } } } }' create_index_template "mdx_bev_template" '{ "index_patterns": ["mdx-bev-*"], "priority": 510, "template": { "settings": { "index.lifecycle.name": "mdx-bev-ilm-policy" }, "mappings": { "properties": { "objects": { "type": "nested", "properties": { "bbox": { "enabled": false }, "bbox3d": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } } } } } }' create_index_template "mdx_space_utilization_template" '{ "index_patterns": ["mdx-space-utilization-*"], "priority": 511, "template": { "settings": { "index.lifecycle.name": "mdx-space-utilization-ilm-policy" }, "mappings": { "properties": { "layouts": { "enabled": false } } } } }' # if rawDataSchema is in json format then comment the following template if [[ "${BP_PROFILE:-}" == "bp_developer_search" ]]; then create_index_template "mdx_raw_template" '{ "index_patterns": ["mdx-raw-*"], "priority": 512, "template": { "settings": { "index.lifecycle.name": "mdx-raw-ilm-policy", "index.mapping.exclude_source_vectors": false }, "mappings": { "properties": { "objects": { "type": "nested", "properties": { "bbox": { "enabled": false }, "bbox3d": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "properties": { "vector": { "type": "dense_vector", "dims": '"${ELASTICSEARCH_RTVI_CV_EMBEDDINGS_DIM}"', "index": true } } }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } } } } } }' echo "Successfully created index template: mdx_raw_template for bp_developer_search" else create_index_template "mdx_raw_template" '{ "index_patterns": ["mdx-raw-*"], "priority": 512, "template": { "settings": { "index.lifecycle.name": "mdx-raw-ilm-policy" }, "mappings": { "properties": { "objects": { "type": "nested", "properties": { "bbox": { "enabled": false }, "bbox3d": { "enabled": false }, "coordinate": { "enabled": false }, "dir": { "enabled": false }, "embedding": { "enabled": false }, "gaze": { "enabled": false }, "lipActivity": { "enabled": false }, "location": { "enabled": false }, "pose": { "enabled": false } } } } } } }' fi create_index_template "mdx_incidents_template" '{ "index_patterns": ["mdx-incidents-*"], "priority": 513, "template": { "settings": { "index.lifecycle.name": "mdx-incidents-ilm-policy" } } }' create_index_template "mdx_embed_filtered_template" '{ "index_patterns": ["mdx-embed-filtered-*"], "priority": 514, "template": { "settings": { "index.lifecycle.name": "mdx-embed-filtered-ilm-policy", "index.mapping.exclude_source_vectors": false }, "mappings": { "properties": { "llm": { "properties": { "visionEmbeddings": { "type": "nested", "properties": { "vector": { "type": "dense_vector", "dims": '"${ELASTICSEARCH_VISION_LLM_EMBEDDINGS_DIM}"', "index": true } } } } } } } } }' create_index_template "mdx_vlm_incidents_template" '{ "index_patterns": ["mdx-vlm-incidents-*"], "priority": 515, "template": { "settings": { "index.lifecycle.name": "mdx-vlm-incidents-ilm-policy" } } }' echo "Successfully created index templates." } ############################ ## function: exit_with_msg ############################ exit_with_msg(){ echo -e "$1 \nExiting Script." exit 1 } ###################### ## Main ###################### main(){ check_ES_status setup_elasticsearch_templates } main ================================================ FILE: deployments/foundational/elk/pb_definitions/ruby/ext_pb.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Generated by the protocol buffer compiler. DO NOT EDIT! # source: ext.proto require 'google/protobuf' require 'google/protobuf/timestamp_pb' require 'schema_pb' Google::Protobuf::DescriptorPool.generated_pool.build do add_file("ext.proto", :syntax => :proto3) do add_message "nv.GeoLocation" do optional :type, :string, 1 repeated :coordinates, :message, 2, "nv.GeoLocation.Point" end add_message "nv.GeoLocation.Point" do repeated :point, :double, 1 end add_message "nv.Behavior" do optional :id, :string, 1 optional :timestamp, :message, 2, "google.protobuf.Timestamp" optional :end, :message, 3, "google.protobuf.Timestamp" optional :locations, :message, 5, "nv.GeoLocation" optional :smoothLocations, :message, 6, "nv.GeoLocation" repeated :edges, :string, 7 optional :distance, :double, 8 optional :speed, :double, 9 repeated :speedOverTime, :double, 10 optional :timeInterval, :double, 11 optional :bearing, :double, 12 optional :direction, :string, 13 optional :length, :int32, 14 optional :place, :message, 15, "nv.Place" optional :sensor, :message, 16, "nv.Sensor" optional :analyticsModule, :message, 17, "nv.AnalyticsModule" optional :object, :message, 18, "nv.Object" optional :event, :message, 19, "nv.Event" optional :videoPath, :string, 20 repeated :poses, :message, 21, "nv.Pose" repeated :lipActivities, :message, 22, "nv.LipActivity" repeated :gazes, :message, 23, "nv.Gaze" repeated :embeddings, :message, 24, "nv.Embedding" optional :llm, :message, 26, "nv.LLM" map :info, :string, :string, 25 end add_message "nv.Incident" do optional :sensorId, :string, 1 optional :timestamp, :message, 2, "google.protobuf.Timestamp" optional :end, :message, 3, "google.protobuf.Timestamp" repeated :objectIds, :string, 4 repeated :frameIds, :string, 5 optional :place, :message, 6, "nv.Place" optional :analyticsModule, :message, 7, "nv.AnalyticsModule" optional :category, :string, 8 repeated :embeddings, :message, 9, "nv.Embedding" optional :isAnomaly, :bool, 10 optional :llm, :message, 12, "nv.LLM" map :info, :string, :string, 11 end add_message "nv.SpaceUtilizationMetrics" do optional :spaceOccupied, :double, 1 optional :freeSpace, :double, 2 optional :totalSpace, :double, 3 optional :spaceUtilization, :double, 4 optional :numExtraPallets, :int32, 5 optional :utilizableFreeSpace, :double, 6 optional :freeSpaceQuality, :double, 7 optional :isUnsafe, :bool, 8 end add_message "nv.SpaceUtilizationLayouts" do repeated :freeSpace, :message, 1, "nv.Polygon" repeated :utilizableFreeSpace, :message, 2, "nv.Polygon" end add_message "nv.SpaceUtilization" do optional :id, :string, 1 optional :timestamp, :message, 2, "google.protobuf.Timestamp" optional :metrics, :message, 3, "nv.SpaceUtilizationMetrics" repeated :sensors, :string, 4 optional :layouts, :message, 5, "nv.SpaceUtilizationLayouts" end end end module Nv GeoLocation = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.GeoLocation").msgclass GeoLocation::Point = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.GeoLocation.Point").msgclass Behavior = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Behavior").msgclass Incident = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Incident").msgclass SpaceUtilizationMetrics = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.SpaceUtilizationMetrics").msgclass SpaceUtilizationLayouts = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.SpaceUtilizationLayouts").msgclass SpaceUtilization = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.SpaceUtilization").msgclass end ================================================ FILE: deployments/foundational/elk/pb_definitions/ruby/schema_pb.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Generated by the protocol buffer compiler. DO NOT EDIT! # source: schema.proto require 'google/protobuf' require 'google/protobuf/timestamp_pb' Google::Protobuf::DescriptorPool.generated_pool.build do add_file("schema.proto", :syntax => :proto3) do add_message "nv.Frame" do optional :version, :string, 1 optional :id, :string, 2 optional :timestamp, :message, 3, "google.protobuf.Timestamp" optional :sensorId, :string, 4 repeated :objects, :message, 5, "nv.Object" repeated :fov, :message, 6, "nv.TypeMetrics" repeated :rois, :message, 7, "nv.TypeMetrics" optional :socialDistancing, :message, 8, "nv.SD" optional :segmentation, :message, 9, "nv.Segmentation" repeated :interactions, :message, 10, "nv.Interaction" repeated :congestions, :message, 11, "nv.Congestion" map :info, :string, :string, 12 end add_message "nv.Object" do optional :id, :string, 1 optional :bbox, :message, 2, "nv.Bbox" optional :type, :string, 3 optional :confidence, :float, 4 map :info, :string, :string, 5 optional :embedding, :message, 6, "nv.Embedding" optional :pose, :message, 7, "nv.Pose" optional :gaze, :message, 8, "nv.Gaze" optional :lipActivity, :message, 9, "nv.LipActivity" optional :speed, :float, 10 repeated :dir, :float, 11 optional :coordinate, :message, 12, "nv.Coordinate" optional :location, :message, 13, "nv.Location" optional :bbox3d, :message, 14, "nv.Bbox3d" end add_message "nv.Coordinate" do optional :x, :double, 1 optional :y, :double, 2 optional :z, :double, 3 end add_message "nv.Location" do optional :lat, :double, 1 optional :lon, :double, 2 optional :alt, :double, 3 end add_message "nv.Bbox" do optional :leftX, :float, 1 optional :topY, :float, 2 optional :rightX, :float, 3 optional :bottomY, :float, 4 repeated :embeddings, :message, 5, "nv.Embedding" optional :confidence, :float, 6 map :info, :string, :string, 7 end add_message "nv.Bbox3d" do repeated :coordinates, :double, 1 repeated :embeddings, :message, 2, "nv.Embedding" optional :confidence, :float, 3 map :info, :string, :string, 4 end add_message "nv.Segmentation" do repeated :mask, :int32, 1 map :info, :string, :string, 2 end add_message "nv.TypeMetrics" do optional :id, :string, 1 optional :type, :string, 2 optional :count, :int32, 3 repeated :coordinates, :message, 4, "nv.Coordinate" repeated :objectIds, :string, 5 map :info, :string, :string, 6 end add_message "nv.Cluster" do repeated :points, :message, 1, "nv.Point2D" end add_message "nv.Point2D" do optional :x, :double, 1 optional :y, :double, 2 end add_message "nv.SD" do optional :threshold, :double, 1 optional :proximityDetections, :int32, 2 repeated :clusters, :message, 3, "nv.Cluster" map :info, :string, :string, 4 end add_message "nv.Polygon" do repeated :coordinates, :message, 1, "nv.Point2D" repeated :holes, :message, 2, "nv.PolygonHole" end add_message "nv.PolygonHole" do repeated :coordinates, :message, 1, "nv.Point2D" end add_message "nv.Interaction" do optional :id, :string, 1 repeated :objectIds, :string, 2 repeated :coordinates, :message, 3, "nv.Coordinate" optional :description, :string, 4 map :info, :string, :string, 5 end add_message "nv.Congestion" do optional :id, :string, 1 repeated :objectIds, :string, 2 optional :amount, :float, 3 map :info, :string, :string, 4 end add_message "nv.Pose" do optional :type, :string, 1 repeated :keypoints, :message, 2, "nv.Pose.Keypoint" repeated :actions, :message, 3, "nv.Pose.Action" map :info, :string, :string, 4 end add_message "nv.Pose.Keypoint" do optional :name, :string, 1 repeated :coordinates, :float, 2 repeated :quaternion, :float, 3 end add_message "nv.Pose.Action" do optional :type, :string, 1 optional :confidence, :float, 2 end add_message "nv.Gaze" do optional :x, :float, 1 optional :y, :float, 2 optional :z, :float, 3 optional :theta, :float, 4 optional :phi, :float, 5 end add_message "nv.LipActivity" do optional :classLabel, :string, 1 end add_message "nv.Event" do optional :id, :string, 1 optional :type, :string, 2 map :info, :string, :string, 5 end add_message "nv.AnalyticsModule" do optional :id, :string, 1 optional :description, :string, 2 optional :source, :string, 3 optional :version, :string, 4 map :info, :string, :string, 5 end add_message "nv.Sensor" do optional :id, :string, 1 optional :type, :string, 2 optional :description, :string, 3 optional :location, :message, 4, "nv.Location" optional :coordinate, :message, 5, "nv.Coordinate" map :info, :string, :string, 6 end add_message "nv.Place" do optional :id, :string, 1 optional :name, :string, 2 optional :type, :string, 3 optional :location, :message, 4, "nv.Location" optional :coordinate, :message, 5, "nv.Coordinate" map :info, :string, :string, 6 end add_message "nv.Message" do optional :messageid, :string, 1 optional :mdsversion, :string, 2 optional :timestamp, :message, 3, "google.protobuf.Timestamp" optional :place, :message, 4, "nv.Place" optional :sensor, :message, 5, "nv.Sensor" optional :analyticsModule, :message, 6, "nv.AnalyticsModule" optional :object, :message, 7, "nv.Object" optional :event, :message, 8, "nv.Event" optional :videoPath, :string, 9 end add_message "nv.Embedding" do repeated :vector, :float, 1 map :info, :string, :string, 2 end add_message "nv.ImageData" do optional :format, :enum, 1, "nv.ImageFormat" optional :encoding, :string, 2 optional :name, :string, 3 optional :data, :bytes, 4 map :info, :string, :string, 5 end add_message "nv.VisionLLM" do optional :version, :string, 1 optional :timestamp, :message, 2, "google.protobuf.Timestamp" optional :end, :message, 3, "google.protobuf.Timestamp" optional :startFrameId, :string, 4 optional :endFrameId, :string, 5 optional :sensor, :message, 6, "nv.Sensor" optional :llm, :message, 7, "nv.LLM" map :info, :string, :string, 8 end add_message "nv.LLM" do map :info, :string, :string, 1 repeated :queries, :message, 2, "nv.Query" repeated :visionEmbeddings, :message, 3, "nv.Embedding" end add_message "nv.Query" do optional :id, :string, 1 map :params, :string, :string, 2 map :prompts, :string, :string, 3 optional :response, :string, 4 repeated :embeddings, :message, 5, "nv.Embedding" end add_enum "nv.ImageFormat" do value :RAW, 0 value :JPG, 1 value :JPEG, 2 value :PNG, 3 end end end module Nv Frame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Frame").msgclass Object = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Object").msgclass Coordinate = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Coordinate").msgclass Location = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Location").msgclass Bbox = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Bbox").msgclass Bbox3d = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Bbox3d").msgclass Segmentation = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Segmentation").msgclass TypeMetrics = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.TypeMetrics").msgclass Cluster = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Cluster").msgclass Point2D = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Point2D").msgclass SD = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.SD").msgclass Polygon = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Polygon").msgclass PolygonHole = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.PolygonHole").msgclass Interaction = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Interaction").msgclass Congestion = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Congestion").msgclass Pose = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Pose").msgclass Pose::Keypoint = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Pose.Keypoint").msgclass Pose::Action = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Pose.Action").msgclass Gaze = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Gaze").msgclass LipActivity = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.LipActivity").msgclass Event = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Event").msgclass AnalyticsModule = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.AnalyticsModule").msgclass Sensor = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Sensor").msgclass Place = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Place").msgclass Message = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Message").msgclass Embedding = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Embedding").msgclass ImageData = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.ImageData").msgclass VisionLLM = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.VisionLLM").msgclass LLM = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.LLM").msgclass Query = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.Query").msgclass ImageFormat = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("nv.ImageFormat").enummodule end ================================================ FILE: deployments/foundational/kafka/init-scripts/create-kafka-topics.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # installing required binaries ARCH=$(uname -m) JQ_URL="" if [ "$ARCH" = "x86_64" ]; then JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" elif [ "$ARCH" = "aarch64" ]; then JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-arm64" else echo "Unsupported architecture: $ARCH" exit 1 fi mkdir -p ~/jqbin curl -L -o ~/jqbin/jq "$JQ_URL" chmod +x ~/jqbin/jq export PATH="/home/appuser/jqbin:${PATH}" # bootstrap kafka hosts KAFKA_HOST=${BOOTSTRAP_HOST:-localhost} KAFKA_PORT=${KAFKA_PORT:-9092} echo 'Waiting for Kafka to come up in order to create the kafka-topics' until kafka-broker-api-versions --bootstrap-server $KAFKA_HOST:$KAFKA_PORT > /dev/null 2>&1; do echo 'Waiting for Kafka services to be up' sleep 2 done echo 'Kafka services are up and running' echo "KAFKA_TOPICS: $KAFKA_TOPICS" echo "DEFAULT_PARTITIONS: $DEFAULT_PARTITIONS" echo "DEFAULT_RETENTION_MS: $DEFAULT_RETENTION_MS" echo "DEFAULT_REPLICATION_FACTOR: $DEFAULT_REPLICATION_FACTOR" echo "DEFAULT_SEGMENT_MS: $DEFAULT_SEGMENT_MS" echo "KAFKA_HOST: $KAFKA_HOST" echo "KAFKA_PORT: $KAFKA_PORT" kafkaTopics=$(echo "$KAFKA_TOPICS" | jq --arg default_partitions "${DEFAULT_PARTITIONS}" \ --arg default_retention_ms "${DEFAULT_RETENTION_MS}" \ --arg default_replication_factor "${DEFAULT_REPLICATION_FACTOR}" \ --arg default_segment_ms "${DEFAULT_SEGMENT_MS}" \ --arg kafka_host "${KAFKA_HOST}:${KAFKA_PORT}" \ -r '.[] | "kafka-topics --create --bootstrap-server \($kafka_host) --topic \(.name) --partitions \(.partitions // $default_partitions) --replication-factor \(.replication_factor // $default_replication_factor) --if-not-exists --config retention.ms=\(.retention_ms // $default_retention_ms) --config segment.ms=\(.segment_ms // $default_segment_ms)"') echo "bootstrap-server: $KAFKA_HOST:$KAFKA_PORT" #Check if Kafka is Up & Running echo "Checking if $KAFKA_HOST:$KAFKA_PORT is reachable" CON_Check=`kafka-broker-api-versions --bootstrap-server $KAFKA_HOST:$KAFKA_PORT > /dev/null 2>&1 && echo "True" || echo "False"` if [[ $CON_Check == True ]] then kafka-topics --bootstrap-server $KAFKA_HOST:$KAFKA_PORT --list echo -e 'Connectivity looks fine, Creating kafka topics' # Create kafka topics using the list provided while IFS= read -r kafkaTopics; do echo "Executing: $kafkaTopics" eval "$kafkaTopics" done <<< "$kafkaTopics" echo -e 'Below kafka topics created successfully created:' kafka-topics --bootstrap-server $KAFKA_HOST:$KAFKA_PORT --list else echo "Kafka is not healthy, Please check if Kafka is Running and $KAFKA_HOST:$KAFKA_PORT is reachable" fi ================================================ FILE: deployments/foundational/kafka-entrypoint.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -e CLUSTER_ID_FILE=/tmp/kafka-data/cluster_id if [ -f "$CLUSTER_ID_FILE" ]; then export KAFKA_CLUSTER_ID=$(cat "$CLUSTER_ID_FILE") echo "Found existing Cluster ID from file: $KAFKA_CLUSTER_ID" else # Generate a new cluster ID TIMESTAMP=$(date -u +'%Y-%m-%dT%H:%M:%S.%3NZ') CLUSTER_STRING="spatial-ai-kafka-cluster-${TIMESTAMP}" export KAFKA_CLUSTER_ID=$(echo -n "$CLUSTER_STRING" | base64 | tr -d '\n') echo "Generated new Cluster ID: $KAFKA_CLUSTER_ID" # Ensure directory exists mkdir -p $(dirname "$CLUSTER_ID_FILE") # Save the cluster ID for future use echo "$KAFKA_CLUSTER_ID" > "$CLUSTER_ID_FILE" fi # Confluent Kafka expects both CLUSTER_ID and KAFKA_CLUSTER_ID export CLUSTER_ID="$KAFKA_CLUSTER_ID" # Execute the original Kafka startup script exec /etc/confluent/docker/run "$@" ================================================ FILE: deployments/foundational/mdx-foundational.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: phoenix: container_name: phoenix image: 'arizephoenix/phoenix:version-8.12.1' profiles: ["bp_wh_2d","bp_smc_2d","bp_ps_2d","bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] environment: PHOENIX_WORKING_DIR: '/.phoenix/' volumes: - phoenix-data:/.phoenix ports: - 6006:6006 elasticsearch: build: context: $MDX_SAMPLE_APPS_DIR/foundational dockerfile: Dockerfiles/elasticsearch.Dockerfile network: "host" image: elasticsearch network_mode: "host" environment: ES_JAVA_OPTS: "-Xmx1024m -Xms256m" discovery.type: single-node profiles: ["bp_wh_2d","bp_wh_kafka_2d${MINIMAL_PROFILE:+_extended}","bp_wh_kafka_3d${MINIMAL_PROFILE:+_extended}","bp_wh_redis_2d${MINIMAL_PROFILE:+_extended}","bp_wh_redis_3d${MINIMAL_PROFILE:+_extended}","bp_smc_2d","bp_ps_2d","playback_kafka_2d","playback_kafka_3d","playback_redis_2d","playback_redis_3d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] volumes: - $MDX_SAMPLE_APPS_DIR/foundational/elk/configs/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro - mdx-elastic-data:/tmp/elastic/data:rw - mdx-elastic-logs:/tmp/elastic/logs:rw container_name: mdx-elastic restart: always healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9200/_cluster/health"] interval: 10s timeout: 10s retries: 15 start_period: 30s elasticsearch-init-container: build: context: $MDX_SAMPLE_APPS_DIR/foundational dockerfile: Dockerfiles/elastic-init.Dockerfile network: "host" profiles: ["bp_wh_2d","bp_wh_kafka_2d${MINIMAL_PROFILE:+_extended}","bp_wh_kafka_3d${MINIMAL_PROFILE:+_extended}","bp_wh_redis_2d${MINIMAL_PROFILE:+_extended}","bp_wh_redis_3d${MINIMAL_PROFILE:+_extended}","bp_smc_2d","bp_ps_2d","playback_kafka_2d","playback_kafka_3d","playback_redis_2d","playback_redis_3d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] network_mode: "host" container_name: mdx-elasticsearch-init environment: - BP_PROFILE=${BP_PROFILE} - ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS=${ELASTICSEARCH_CONNECTION_MAX_ATTEMPTS:-20} - ELASTICSEARCH_ILM_MIN_AGE=${ELASTICSEARCH_ILM_MIN_AGE:-4h} - ELASTICSEARCH_RTVI_CV_EMBEDDINGS_DIM=${ELASTICSEARCH_RTVI_CV_EMBEDDINGS_DIM:-1536} - ELASTICSEARCH_VISION_LLM_EMBEDDINGS_DIM=${ELASTICSEARCH_VISION_LLM_EMBEDDINGS_DIM:-768} command: bash -c " /opt/mdx/init-scripts/elasticsearch-ilm-policy-creation.sh && /opt/mdx/init-scripts/elasticsearch-template-creation.sh && /opt/mdx/init-scripts/elasticsearch-ingest-pipeline-creation.sh " depends_on: - elasticsearch kafka: image: confluentinc/cp-kafka:8.1.1 network_mode: "host" volumes: - mdx-kafka:/tmp/kafka-data - $MDX_SAMPLE_APPS_DIR/foundational/kafka-entrypoint.sh:/usr/local/bin/kafka-entrypoint.sh:ro profiles: ["bp_wh_2d","bp_wh_kafka_2d","bp_wh_kafka_3d","bp_smc_2d","bp_ps_2d","playback_kafka_2d","playback_kafka_3d","bp_developer_search_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] environment: KAFKA_BROKER_ID: 1 KAFKA_PROCESS_ROLES: 'broker,controller' KAFKA_NODE_ID: 1 KAFKA_CONTROLLER_QUORUM_VOTERS: '1@localhost:9093' KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://localhost:9093 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://$HOST_IP:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false' KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 KAFKA_HEAP_OPTS: "-Xmx6G -Xms6G" KAFKA_MAX_REQUEST_SIZE: 10485760 KAFKA_MESSAGE_MAX_BYTES: 10485760 KAFKA_MAX_PARTITION_FETCH_BYTES: 10485760 KAFKA_REPLICA_FETCH_MAX_BYTES: 10485760 KAFKA_LOG_DIRS: '/tmp/kafka-data' container_name: mdx-kafka entrypoint: ["/bin/sh","/usr/local/bin/kafka-entrypoint.sh"] healthcheck: test: ["CMD", "sh", "-c", "kafka-topics --bootstrap-server localhost:9092 --list || exit 1"] interval: 15s timeout: 15s retries: 10 start_period: 60s kafka-topic-init-container: image: confluentinc/cp-kafka:8.1.1 container_name: mdx-kafka-topics network_mode: "host" profiles: ["bp_wh_2d","bp_wh_kafka_2d","bp_wh_kafka_3d","bp_smc_2d","bp_ps_2d","playback_kafka_2d","playback_kafka_3d","bp_developer_search_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] volumes: - $MDX_SAMPLE_APPS_DIR/foundational/kafka/init-scripts/create-kafka-topics.sh:/usr/bin/create-kafka-topics.sh environment: DEFAULT_PARTITIONS: "8" DEFAULT_RETENTION_MS: "14400000" DEFAULT_SEGMENT_MS: "3600000" DEFAULT_REPLICATION_FACTOR: "1" BOOTSTRAP_HOST: localhost KAFKA_PORT: 9092 KAFKA_TOPICS: '[{"name": "mdx-raw"}, {"name": "mdx-bev"}, {"name": "mdx-space-utilization"}, {"name": "mdx-alerts"}, {"name": "mdx-behavior", "retention_ms": "14400000", "segment_ms": "3600000", "replication_factor": "1"}, {"name": "mdx-behavior-plus"}, {"name": "mdx-frames"}, {"name": "mdx-mtmc"}, {"name": "mdx-rtls"}, {"name": "mdx-rtls-region-1"}, {"name": "mdx-amr"}, {"name": "mdx-vlm-alerts"}, {"name": "mdx-notification", "partitions": "1"}, {"name": "mdx-events"}, {"name": "mdx-incidents"}, {"name": "mdx-vlm-incidents"}, {"name": "mdx-vlm"}, {"name": "mdx-embed"}, {"name": "mdx-embed-filtered"}]' depends_on: kafka: condition: service_healthy command: "bash /usr/bin/create-kafka-topics.sh" redis: image: redis:8.2.2-alpine profiles: ["bp_wh_2d","bp_wh_redis_2d","bp_wh_redis_3d","bp_wh_kafka_2d","bp_wh_kafka_3d","bp_smc_2d","bp_ps_2d","bp_developer_base_2d","playback_redis_2d","playback_redis_3d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] restart: always command: redis-server /config/redis.conf network_mode: host container_name: mdx-redis volumes: - $MDX_SAMPLE_APPS_DIR/foundational/redis/configs/redis.conf:/config/redis.conf - $MDX_DATA_DIR/data_log/redis/data:/data - $MDX_DATA_DIR/data_log/redis/log:/log kibana: image: docker.elastic.co/kibana/kibana:9.3.0 network_mode: "host" profiles: ["bp_wh_2d","bp_wh_kafka_2d${MINIMAL_PROFILE:+_extended}","bp_wh_kafka_3d${MINIMAL_PROFILE:+_extended}","bp_wh_redis_2d${MINIMAL_PROFILE:+_extended}","bp_wh_redis_3d${MINIMAL_PROFILE:+_extended}","bp_smc_2d","bp_ps_2d","playback_kafka_2d","playback_kafka_3d","playback_redis_2d","playback_redis_3d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] volumes: - $MDX_SAMPLE_APPS_DIR/foundational/elk/configs/kibana.yml:/usr/share/kibana/config/kibana.yml:ro environment: SERVER_PUBLICBASEURL: ${KIBANA_PUBLIC_URL:-http://localhost:5601} SERVER_SECURITYRESPONSEHEADERS_DISABLEEMBEDDING: "false" CSP_STRICT: "false" container_name: mdx-kibana restart: always depends_on: elasticsearch: condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5601/api/status"] interval: 10s timeout: 10s retries: 30 start_period: 60s logstash: image: docker.elastic.co/logstash/logstash:9.3.0 network_mode: "host" profiles: ["bp_wh_2d","bp_wh_kafka_2d${MINIMAL_PROFILE:+_extended}","bp_wh_kafka_3d${MINIMAL_PROFILE:+_extended}","bp_wh_redis_2d${MINIMAL_PROFILE:+_extended}","bp_wh_redis_3d${MINIMAL_PROFILE:+_extended}","bp_smc_2d","bp_ps_2d","playback_kafka_2d","playback_kafka_3d","playback_redis_2d","playback_redis_3d","bp_developer_search_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] volumes: - mdx-logstash-libs:/opt/logstash-data-libs - $MDX_SAMPLE_APPS_DIR/foundational/elk/pb_definitions/ruby/ext_pb.rb:/opt/logstash-data-libs/logstash/pb_definitions/ext_pb.rb - $MDX_SAMPLE_APPS_DIR/foundational/elk/pb_definitions/ruby/schema_pb.rb:/opt/logstash-data-libs/logstash/pb_definitions/schema_pb.rb - $MDX_SAMPLE_APPS_DIR/foundational/elk/pb_definitions/descriptors/ext.desc:/opt/logstash-data-libs/logstash/pb_definitions/descriptors/ext.desc - $MDX_SAMPLE_APPS_DIR/foundational/elk/pb_definitions/descriptors/schema.desc:/opt/logstash-data-libs/logstash/pb_definitions/descriptors/schema.desc - $MDX_SAMPLE_APPS_DIR/foundational/elk/configs/logstash.yml:/usr/share/logstash/config/logstash.yml - $MDX_SAMPLE_APPS_DIR/foundational/elk/configs/mdx-${STREAM_TYPE}-logstash.conf:/usr/share/logstash/pipeline/logstash.conf - $MDX_SAMPLE_APPS_DIR/foundational/elk/gems/logstash-input-redis_stream-3.1.0-java.gem:/usr/share/logstash/gems/logstash-redis-stream-input-java.gem environment: LS_JAVA_OPTS: "-Xmx1024m -Xms256m" STREAM_TYPE: ${STREAM_TYPE} container_name: mdx-logstash restart: always depends_on: broker-health-check: condition: service_completed_successfully elasticsearch-init-container: condition: service_completed_successfully command: > bash -c " echo 'Installing gems for mdx-apps.. This may take sometime to install plugins...' && ls -al /opt/logstash-data-libs/ && if [ \"$$STREAM_TYPE\" = 'redis' ]; then echo 'Installing Redis stream input plugin...' && /usr/share/logstash/bin/logstash-plugin install /usr/share/logstash/gems/logstash-redis-stream-input-java.gem; elif [ \"$$STREAM_TYPE\" = 'kafka' ]; then echo 'Installing protobuf codec plugin...' && /usr/share/logstash/bin/logstash-plugin install logstash-codec-protobuf; fi && echo 'Installed gems for logstash plugins' && /usr/local/bin/docker-entrypoint" broker-health-check: build: context: $MDX_SAMPLE_APPS_DIR/foundational dockerfile: Dockerfiles/${STREAM_TYPE}-health-check.Dockerfile network: host profiles: ["bp_wh_2d","bp_wh_redis_3d","bp_wh_redis_2d","bp_wh_kafka_2d","bp_wh_kafka_3d","bp_smc_2d","bp_ps_2d","playback_kafka_2d","playback_kafka_3d","playback_redis_2d","playback_redis_3d","bp_developer_search_2d","bp_developer_alerts_2d_cv", "bp_developer_alerts_2d_vlm"] network_mode: "host" environment: MAX_RETRIES: 60 RETRY_INTERVAL: 2 BOOTSTRAP_HOST: localhost KAFKA_PORT: 9092 REDIS_PORT: 6379 KAFKA_TOPICS: '[{"name": "mdx-raw"}, {"name": "mdx-bev"}, {"name": "mdx-space-utilization"}, {"name": "mdx-alerts"}, {"name": "mdx-behavior"}, {"name": "mdx-behavior-plus"}, {"name": "mdx-frames"}, {"name": "mdx-mtmc"}, {"name": "mdx-rtls"}, {"name": "mdx-rtls-region-1"}, {"name": "mdx-amr"}, {"name": "mdx-vlm-alerts"}, {"name": "mdx-notification"}, {"name": "mdx-events"}, {"name": "mdx-incidents"}, {"name": "mdx-vlm-incidents"}, {"name": "mdx-vlm"}, {"name": "mdx-embed"}, {"name": "mdx-embed-filtered"}]' container_name: mdx-broker-health-check restart: "no" volumes: phoenix-data: mdx-logstash-libs: mdx-elastic-data: driver: local driver_opts: type: none o: bind device: $MDX_DATA_DIR/data_log/elastic/data mdx-elastic-logs: driver: local driver_opts: type: none o: bind device: $MDX_DATA_DIR/data_log/elastic/logs mdx-kafka: driver: local driver_opts: type: none o: bind device: $MDX_DATA_DIR/data_log/kafka ================================================ FILE: deployments/foundational/redis/configs/redis.conf ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Redis configuration. # # Note that in order to read the configuration file, Redis must be # started with the file path as first argument: # # ./redis-server /path/to/redis.conf # Note on units: when memory size is needed, it is possible to specify # it in the usual form of 1k 5GB 4M and so forth: # # 1k => 1000 bytes # 1kb => 1024 bytes # 1m => 1000000 bytes # 1mb => 1024*1024 bytes # 1g => 1000000000 bytes # 1gb => 1024*1024*1024 bytes # # units are case insensitive so 1GB 1Gb 1gB are all the same. ################################## INCLUDES ################################### # Include one or more other config files here. This is useful if you # have a standard template that goes to all Redis servers but also need # to customize a few per-server settings. Include files can include # other files, so use this wisely. # # Note that option "include" won't be rewritten by command "CONFIG REWRITE" # from admin or Redis Sentinel. Since Redis always uses the last processed # line as value of a configuration directive, you'd better put includes # at the beginning of this file to avoid overwriting config change at runtime. # # If instead you are interested in using includes to override configuration # options, it is better to use include as the last line. # # Included paths may contain wildcards. All files matching the wildcards will # be included in alphabetical order. # Note that if an include path contains a wildcards but no files match it when # the server is started, the include statement will be ignored and no error will # be emitted. It is safe, therefore, to include wildcard files from empty # directories. # # include /path/to/local.conf # include /path/to/other.conf # include /path/to/fragments/*.conf # ################################## MODULES ##################################### # Load modules at startup. If the server is not able to load modules # it will abort. It is possible to use multiple loadmodule directives. # # loadmodule /path/to/my_module.so # loadmodule /path/to/other_module.so # loadmodule /path/to/args_module.so [arg [arg ...]] ################################## NETWORK ##################################### # By default, if no "bind" configuration directive is specified, Redis listens # for connections from all available network interfaces on the host machine. # It is possible to listen to just one or multiple selected interfaces using # the "bind" configuration directive, followed by one or more IP addresses. # Each address can be prefixed by "-", which means that redis will not fail to # start if the address is not available. Being not available only refers to # addresses that does not correspond to any network interface. Addresses that # are already in use will always fail, and unsupported protocols will always BE # silently skipped. # # Examples: # # bind 192.168.1.100 10.0.0.1 # listens on two specific IPv4 addresses # bind 127.0.0.1 ::1 # listens on loopback IPv4 and IPv6 # bind * -::* # like the default, all available interfaces # # ~~~ WARNING ~~~ If the computer running Redis is directly exposed to the # internet, binding to all the interfaces is dangerous and will expose the # instance to everybody on the internet. So by default we uncomment the # following bind directive, that will force Redis to listen only on the # IPv4 and IPv6 (if available) loopback interface addresses (this means Redis # will only be able to accept client connections from the same host that it is # running on). # # IF YOU ARE SURE YOU WANT YOUR INSTANCE TO LISTEN TO ALL THE INTERFACES # COMMENT OUT THE FOLLOWING LINE. # # You will also need to set a password unless you explicitly disable protected # mode. # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ bind 0.0.0.0 # By default, outgoing connections (from replica to master, from Sentinel to # instances, cluster bus, etc.) are not bound to a specific local address. In # most cases, this means the operating system will handle that based on routing # and the interface through which the connection goes out. # # Using bind-source-addr it is possible to configure a specific address to bind # to, which may also affect how the connection gets routed. # # Example: # # bind-source-addr 10.0.0.1 # Protected mode is a layer of security protection, in order to avoid that # Redis instances left open on the internet are accessed and exploited. # # When protected mode is on and the default user has no password, the server # only accepts local connections from the IPv4 address (127.0.0.1), IPv6 address # (::1) or Unix domain sockets. # # By default protected mode is enabled. You should disable it only if # you are sure you want clients from other hosts to connect to Redis # even if no authentication is configured. protected-mode no # Redis uses default hardened security configuration directives to reduce the # attack surface on innocent users. Therefore, several sensitive configuration # directives are immutable, and some potentially-dangerous commands are blocked. # # Configuration directives that control files that Redis writes to (e.g., 'dir' # and 'dbfilename') and that aren't usually modified during runtime # are protected by making them immutable. # # Commands that can increase the attack surface of Redis and that aren't usually # called by users are blocked by default. # # These can be exposed to either all connections or just local ones by setting # each of the configs listed below to either of these values: # # no - Block for any connection (remain immutable) # yes - Allow for any connection (no protection) # local - Allow only for local connections. Ones originating from the # IPv4 address (127.0.0.1), IPv6 address (::1) or Unix domain sockets. # # enable-protected-configs no # enable-debug-command no # enable-module-command no # Accept connections on the specified port, default is 6379 (IANA #815344). # If port 0 is specified Redis will not listen on a TCP socket. port 6379 # TCP listen() backlog. # # In high requests-per-second environments you need a high backlog in order # to avoid slow clients connection issues. Note that the Linux kernel # will silently truncate it to the value of /proc/sys/net/core/somaxconn so # make sure to raise both the value of somaxconn and tcp_max_syn_backlog # in order to get the desired effect. tcp-backlog 511 # Unix socket. # # Specify the path for the Unix socket that will be used to listen for # incoming connections. There is no default, so Redis will not listen # on a unix socket when not specified. # # unixsocket /run/redis.sock # unixsocketperm 700 # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 # TCP keepalive. # # If non-zero, use SO_KEEPALIVE to send TCP ACKs to clients in absence # of communication. This is useful for two reasons: # # 1) Detect dead peers. # 2) Force network equipment in the middle to consider the connection to be # alive. # # On Linux, the specified value (in seconds) is the period used to send ACKs. # Note that to close the connection the double of the time is needed. # On other kernels the period depends on the kernel configuration. # # A reasonable value for this option is 300 seconds, which is the new # Redis default starting with Redis 3.2.1. tcp-keepalive 300 # Apply OS-specific mechanism to mark the listening socket with the specified # ID, to support advanced routing and filtering capabilities. # # On Linux, the ID represents a connection mark. # On FreeBSD, the ID represents a socket cookie ID. # On OpenBSD, the ID represents a route table ID. # # The default value is 0, which implies no marking is required. # socket-mark-id 0 ################################# TLS/SSL ##################################### # By default, TLS/SSL is disabled. To enable it, the "tls-port" configuration # directive can be used to define TLS-listening ports. To enable TLS on the # default port, use: # # port 0 # tls-port 6379 # Configure a X.509 certificate and private key to use for authenticating the # server to connected clients, masters or cluster peers. These files should be # PEM formatted. # # tls-cert-file redis.crt # tls-key-file redis.key # # If the key file is encrypted using a passphrase, it can be included here # as well. # # tls-key-file-pass secret # Normally Redis uses the same certificate for both server functions (accepting # connections) and client functions (replicating from a master, establishing # cluster bus connections, etc.). # # Sometimes certificates are issued with attributes that designate them as # client-only or server-only certificates. In that case it may be desired to use # different certificates for incoming (server) and outgoing (client) # connections. To do that, use the following directives: # # tls-client-cert-file client.crt # tls-client-key-file client.key # # If the key file is encrypted using a passphrase, it can be included here # as well. # # tls-client-key-file-pass secret # Configure a DH parameters file to enable Diffie-Hellman (DH) key exchange, # required by older versions of OpenSSL (<3.0). Newer versions do not require # this configuration and recommend against it. # # tls-dh-params-file redis.dh # Configure a CA certificate(s) bundle or directory to authenticate TLS/SSL # clients and peers. Redis requires an explicit configuration of at least one # of these, and will not implicitly use the system wide configuration. # # tls-ca-cert-file ca.crt # tls-ca-cert-dir /etc/ssl/certs # By default, clients (including replica servers) on a TLS port are required # to authenticate using valid client side certificates. # # If "no" is specified, client certificates are not required and not accepted. # If "optional" is specified, client certificates are accepted and must be # valid if provided, but are not required. # # tls-auth-clients no # tls-auth-clients optional # By default, a Redis replica does not attempt to establish a TLS connection # with its master. # # Use the following directive to enable TLS on replication links. # # tls-replication yes # By default, the Redis Cluster bus uses a plain TCP connection. To enable # TLS for the bus protocol, use the following directive: # # tls-cluster yes # By default, only TLSv1.2 and TLSv1.3 are enabled and it is highly recommended # that older formally deprecated versions are kept disabled to reduce the attack surface. # You can explicitly specify TLS versions to support. # Allowed values are case insensitive and include "TLSv1", "TLSv1.1", "TLSv1.2", # "TLSv1.3" (OpenSSL >= 1.1.1) or any combination. # To enable only TLSv1.2 and TLSv1.3, use: # # tls-protocols "TLSv1.2 TLSv1.3" # Configure allowed ciphers. See the ciphers(1ssl) manpage for more information # about the syntax of this string. # # Note: this configuration applies only to <= TLSv1.2. # # tls-ciphers DEFAULT:!MEDIUM # Configure allowed TLSv1.3 ciphersuites. See the ciphers(1ssl) manpage for more # information about the syntax of this string, and specifically for TLSv1.3 # ciphersuites. # # tls-ciphersuites TLS_CHACHA20_POLY1305_SHA256 # When choosing a cipher, use the server's preference instead of the client # preference. By default, the server follows the client's preference. # # tls-prefer-server-ciphers yes # By default, TLS session caching is enabled to allow faster and less expensive # reconnections by clients that support it. Use the following directive to disable # caching. # # tls-session-caching no # Change the default number of TLS sessions cached. A zero value sets the cache # to unlimited size. The default size is 20480. # # tls-session-cache-size 5000 # Change the default timeout of cached TLS sessions. The default timeout is 300 # seconds. # # tls-session-cache-timeout 60 ################################# GENERAL ##################################### # By default Redis does not run as a daemon. Use 'yes' if you need it. # Note that Redis will write a pid file in /var/run/redis.pid when daemonized. # When Redis is supervised by upstart or systemd, this parameter has no impact. daemonize no # If you run Redis from upstart or systemd, Redis can interact with your # supervision tree. Options: # supervised no - no supervision interaction # supervised upstart - signal upstart by putting Redis into SIGSTOP mode # requires "expect stop" in your upstart job config # supervised systemd - signal systemd by writing READY=1 to $NOTIFY_SOCKET # on startup, and updating Redis status on a regular # basis. # supervised auto - detect upstart or systemd method based on # UPSTART_JOB or NOTIFY_SOCKET environment variables # Note: these supervision methods only signal "process is ready." # They do not enable continuous pings back to your supervisor. # # The default is "no". To run under upstart/systemd, you can simply uncomment # the line below: # # supervised auto # If a pid file is specified, Redis writes it where specified at startup # and removes it at exit. # # When the server runs non daemonized, no pid file is created if none is # specified in the configuration. When the server is daemonized, the pid file # is used even if not specified, defaulting to "/var/run/redis.pid". # # Creating a pid file is best effort: if Redis is not able to create it # nothing bad happens, the server will start and run normally. # # Note: In Docker containers, it's common to disable pidfile since the container # manages the process lifecycle. pidfile "" # Specify the server verbosity level. # This can be one of: # debug (a lot of information, useful for development/testing) # verbose (many rarely useful info, but not a mess like the debug level) # notice (moderately verbose, what you want in production probably) # warning (only very important / critical messages are logged) # nothing (nothing is logged) loglevel notice # Specify the log file name. Also the empty string can be used to force # Redis to log on the standard output. Note that if you use standard # output for logging but daemonize, logs will be sent to /dev/null logfile "/log/redis.log" # To enable logging to the system logger, just set 'syslog-enabled' to yes, # and optionally update the other syslog parameters to suit your needs. # syslog-enabled no # Specify the syslog identity. # syslog-ident redis # Specify the syslog facility. Must be USER or between LOCAL0-LOCAL7. # syslog-facility local0 # To disable the built in crash log, which will possibly produce cleaner core # dumps when they are needed, uncomment the following: # # crash-log-enabled no # To disable the fast memory check that's run as part of the crash log, which # will possibly let redis terminate sooner, uncomment the following: # # crash-memcheck-enabled no # Set the number of databases. The default database is DB 0, you can select # a different one on a per-connection basis using SELECT where # dbid is a number between 0 and 'databases'-1 databases 16 # By default Redis shows an ASCII art logo only when started to log to the # standard output and if the standard output is a TTY and syslog logging is # disabled. Basically this means that normally a logo is displayed only in # interactive sessions. # # However it is possible to force the pre-4.0 behavior and always show a # ASCII art logo in startup logs by setting the following option to yes. always-show-logo yes # To avoid logging personal identifiable information (PII) into server log file, # uncomment the following: # # hide-user-data-from-log yes # By default, Redis modifies the process title (as seen in 'top' and 'ps') to # provide some runtime information. It is possible to disable this and leave # the process name as executed by setting the following to no. set-proc-title yes # When changing the process title, Redis uses the following template to construct # the modified title. # # Template variables are specified in curly brackets. The following variables are # supported: # # {title} Name of process as executed if parent, or type of child process. # {listen-addr} Bind address or '*' followed by TCP or TLS port listening on, or # Unix socket if only that's available. # {server-mode} Special mode, i.e. "[sentinel]" or "[cluster]". # {port} TCP port listening on, or 0. # {tls-port} TLS port listening on, or 0. # {unixsocket} Unix domain socket listening on, or "". # {config-file} Name of configuration file used. # proc-title-template "{title} {listen-addr} {server-mode}" # Set the local environment which is used for string comparison operations, and # also affect the performance of Lua scripts. Empty String indicates the locale # is derived from the environment variables. locale-collate "" ################################ SNAPSHOTTING ################################ # Save the DB to disk. # # save [ ...] # # Redis will save the DB if the given number of seconds elapsed and it # surpassed the given number of write operations against the DB. # # Snapshotting can be completely disabled with a single empty string argument # as in following example: # # save "" # # Unless specified otherwise, by default Redis will save the DB: # * After 3600 seconds (an hour) if at least 1 change was performed # * After 300 seconds (5 minutes) if at least 100 changes were performed # * After 60 seconds if at least 10000 changes were performed # # You can set these explicitly by uncommenting the following line. # save 60 1 # By default Redis will stop accepting writes if RDB snapshots are enabled # (at least one save point) and the latest background save failed. # This will make the user aware (in a hard way) that data is not persisting # on disk properly, otherwise chances are that no one will notice and some # disaster will happen. # # If the background saving process will start working again Redis will # automatically allow writes again. # # However if you have setup your proper monitoring of the Redis server # and persistence, you may want to disable this feature so that Redis will # continue to work as usual even if there are problems with disk, # permissions, and so forth. stop-writes-on-bgsave-error no # Compress string objects using LZF when dump .rdb databases? # By default compression is enabled as it's almost always a win. # If you want to save some CPU in the saving child set it to 'no' but # the dataset will likely be bigger if you have compressible values or keys. rdbcompression yes # Since version 5 of RDB a CRC64 checksum is placed at the end of the file. # This makes the format more resistant to corruption but there is a performance # hit to pay (around 10%) when saving and loading RDB files, so you can disable it # for maximum performances. # # RDB files created with checksum disabled have a checksum of zero that will # tell the loading code to skip the check. rdbchecksum yes # Enables or disables full sanitization checks for ziplist and listpack etc when # loading an RDB or RESTORE payload. This reduces the chances of a assertion or # crash later on while processing commands. # Options: # no - Never perform full sanitization # yes - Always perform full sanitization # clients - Perform full sanitization only for user connections. # Excludes: RDB files, RESTORE commands received from the master # connection, and client connections which have the # skip-sanitize-payload ACL flag. # The default should be 'clients' but since it currently affects cluster # resharding via MIGRATE, it is temporarily set to 'no' by default. # # sanitize-dump-payload no # The filename where to dump the DB dbfilename dump.rdb # Remove RDB files used by replication in instances without persistence # enabled. By default this option is disabled, however there are environments # where for regulations or other security concerns, RDB files persisted on # disk by masters in order to feed replicas, or stored on disk by replicas # in order to load them for the initial synchronization, should be deleted # ASAP. Note that this option ONLY WORKS in instances that have both AOF # and RDB persistence disabled, otherwise is completely ignored. # # An alternative (and sometimes better) way to obtain the same effect is # to use diskless replication on both master and replicas instances. However # in the case of replicas, diskless is not always an option. rdb-del-sync-files no # The working directory. # # The DB will be written inside this directory, with the filename specified # above using the 'dbfilename' configuration directive. # # The Append Only File will also be created inside this directory. # # Note that you must specify a directory here, not a file name. dir /data/ ################################# REPLICATION ################################# # Master-Replica replication. Use replicaof to make a Redis instance a copy of # another Redis server. A few things to understand ASAP about Redis replication. # # +------------------+ +---------------+ # | Master | ---> | Replica | # | (receive writes) | | (exact copy) | # +------------------+ +---------------+ # # 1) Redis replication is asynchronous, but you can configure a master to # stop accepting writes if it appears to be not connected with at least # a given number of replicas. # 2) Redis replicas are able to perform a partial resynchronization with the # master if the replication link is lost for a relatively small amount of # time. You may want to configure the replication backlog size (see the next # sections of this file) with a sensible value depending on your needs. # 3) Replication is automatic and does not need user intervention. After a # network partition replicas automatically try to reconnect to masters # and resynchronize with them. # # replicaof # If the master is password protected (using the "requirepass" configuration # directive below) it is possible to tell the replica to authenticate before # starting the replication synchronization process, otherwise the master will # refuse the replica request. # # masterauth # # However this is not enough if you are using Redis ACLs (for Redis version # 6 or greater), and the default user is not capable of running the PSYNC # command and/or other commands needed for replication. In this case it's # better to configure a special user to use with replication, and specify the # masteruser configuration as such: # # masteruser # # When masteruser is specified, the replica will authenticate against its # master using the new AUTH form: AUTH . # When a replica loses its connection with the master, or when the replication # is still in progress, the replica can act in two different ways: # # 1) if replica-serve-stale-data is set to 'yes' (the default) the replica will # still reply to client requests, possibly with out of date data, or the # data set may just be empty if this is the first synchronization. # # 2) If replica-serve-stale-data is set to 'no' the replica will reply with error # "MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'" # to all data access commands, excluding commands such as: # INFO, REPLICAOF, AUTH, SHUTDOWN, REPLCONF, ROLE, CONFIG, SUBSCRIBE, # UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, PUBSUB, COMMAND, POST, # HOST and LATENCY. # replica-serve-stale-data yes # You can configure a replica instance to accept writes or not. Writing against # a replica instance may be useful to store some ephemeral data (because data # written on a replica will be easily deleted after resync with the master) but # may also cause problems if clients are writing to it because of a # misconfiguration. # # Since Redis 2.6 by default replicas are read-only. # # Note: read only replicas are not designed to be exposed to untrusted clients # on the internet. It's just a protection layer against misuse of the instance. # Still a read only replica exports by default all the administrative commands # such as CONFIG, DEBUG, and so forth. To a limited extent you can improve # security of read only replicas using 'rename-command' to shadow all the # administrative / dangerous commands. replica-read-only yes # Replication SYNC strategy: disk or socket. # # New replicas and reconnecting replicas that are not able to continue the # replication process just receiving differences, need to do what is called a # "full synchronization". An RDB file is transmitted from the master to the # replicas. # # The transmission can happen in two different ways: # # 1) Disk-backed: The Redis master creates a new process that writes the RDB # file on disk. Later the file is transferred by the parent # process to the replicas incrementally. # 2) Diskless: The Redis master creates a new process that directly writes the # RDB file to replica sockets, without touching the disk at all. # # With disk-backed replication, while the RDB file is generated, more replicas # can be queued and served with the RDB file as soon as the current child # producing the RDB file finishes its work. With diskless replication instead # once the transfer starts, new replicas arriving will be queued and a new # transfer will start when the current one terminates. # # When diskless replication is used, the master waits a configurable amount of # time (in seconds) before starting the transfer in the hope that multiple # replicas will arrive and the transfer can be parallelized. # # With slow disks and fast (large bandwidth) networks, diskless replication # works better. repl-diskless-sync no # When diskless replication is enabled, it is possible to configure the delay # the server waits in order to spawn the child that transfers the RDB via socket # to the replicas. # # This is important since once the transfer starts, it is not possible to serve # new replicas arriving, that will be queued for the next RDB transfer, so the # server waits a delay in order to let more replicas arrive. # # The delay is specified in seconds, and by default is 5 seconds. To disable # it entirely just set it to 0 seconds and the transfer will start ASAP. repl-diskless-sync-delay 5 # When diskless replication is enabled with a delay, it is possible to let # the replication start before the maximum delay is reached if the maximum # number of replicas expected have connected. Default of 0 means that the # maximum is not defined and Redis will wait the full delay. repl-diskless-sync-max-replicas 0 # ----------------------------------------------------------------------------- # WARNING: Since in this setup the replica does not immediately store an RDB on # disk, it may cause data loss during failovers. RDB diskless load + Redis # modules not handling I/O reads may cause Redis to abort in case of I/O errors # during the initial synchronization stage with the master. # ----------------------------------------------------------------------------- # # Replica can load the RDB it reads from the replication link directly from the # socket, or store the RDB to a file and read that file after it was completely # received from the master. # # In many cases the disk is slower than the network, and storing and loading # the RDB file may increase replication time (and even increase the master's # Copy on Write memory and replica buffers). # However, when parsing the RDB file directly from the socket, in order to avoid # data loss it's only safe to flush the current dataset when the new dataset is # fully loaded in memory, resulting in higher memory usage. # For this reason we have the following options: # # "disabled" - Don't use diskless load (store the rdb file to the disk first) # "swapdb" - Keep current db contents in RAM while parsing the data directly # from the socket. Replicas in this mode can keep serving current # dataset while replication is in progress, except for cases where # they can't recognize master as having a data set from same # replication history. # Note that this requires sufficient memory, if you don't have it, # you risk an OOM kill. # "on-empty-db" - Use diskless load only when current dataset is empty. This is # safer and avoid having old and new dataset loaded side by side # during replication. repl-diskless-load disabled # Master send PINGs to its replicas in a predefined interval. It's possible to # change this interval with the repl-ping-replica-period option. The default # value is 10 seconds. # # repl-ping-replica-period 10 # The following option sets the replication timeout for: # # 1) Bulk transfer I/O during SYNC, from the point of view of replica. # 2) Master timeout from the point of view of replicas (data, pings). # 3) Replica timeout from the point of view of masters (REPLCONF ACK pings). # # It is important to make sure that this value is greater than the value # specified for repl-ping-replica-period otherwise a timeout will be detected # every time there is low traffic between the master and the replica. The default # value is 60 seconds. # # repl-timeout 60 # Disable TCP_NODELAY on the replica socket after SYNC? # # If you select "yes" Redis will use a smaller number of TCP packets and # less bandwidth to send data to replicas. But this can add a delay for # the data to appear on the replica side, up to 40 milliseconds with # Linux kernels using a default configuration. # # If you select "no" the delay for data to appear on the replica side will # be reduced but more bandwidth will be used for replication. # # By default we optimize for low latency, but in very high traffic conditions # or when the master and replicas are many hops away, turning this to "yes" may # be a good idea. repl-disable-tcp-nodelay no # Set the replication backlog size. The backlog is a buffer that accumulates # replica data when replicas are disconnected for some time, so that when a # replica wants to reconnect again, often a full resync is not needed, but a # partial resync is enough, just passing the portion of data the replica # missed while disconnected. # # The bigger the replication backlog, the longer the replica can endure the # disconnect and later be able to perform a partial resynchronization. # # The backlog is only allocated if there is at least one replica connected. # # repl-backlog-size 1mb # After a master has no connected replicas for some time, the backlog will be # freed. The following option configures the amount of seconds that need to # elapse, starting from the time the last replica disconnected, for the backlog # buffer to be freed. # # Note that replicas never free the backlog for timeout, since they may be # promoted to masters later, and should be able to correctly "partially # resynchronize" with other replicas: hence they should always accumulate backlog. # # A value of 0 means to never release the backlog. # # repl-backlog-ttl 3600 # During a fullsync, the master may decide to send both the RDB file and the # replication stream to the replica in parallel. This approach shifts the # responsibility of buffering the replication stream to the replica during the # fullsync process. The replica accumulates the replication stream data until # the RDB file is fully loaded. Once the RDB delivery is completed and # successfully loaded, the replica begins processing and applying the # accumulated replication data to the db. The configuration below controls how # much replication data the replica can accumulate during a fullsync. # # When the replica reaches this limit, it will stop accumulating further data. # At this point, additional data accumulation may occur on the master side # depending on the 'client-output-buffer-limit ' config of master. # # A value of 0 means replica inherits hard limit of # 'client-output-buffer-limit ' config to limit accumulation size. # # replica-full-sync-buffer-limit 0 # The replica priority is an integer number published by Redis in the INFO # output. It is used by Redis Sentinel in order to select a replica to promote # into a master if the master is no longer working correctly. # # A replica with a low priority number is considered better for promotion, so # for instance if there are three replicas with priority 10, 100, 25 Sentinel # will pick the one with priority 10, that is the lowest. # # However a special priority of 0 marks the replica as not able to perform the # role of master, so a replica with priority of 0 will never be selected by # Redis Sentinel for promotion. # # By default the priority is 100. replica-priority 100 # The propagation error behavior controls how Redis will behave when it is # unable to handle a command being processed in the replication stream from a master # or processed while reading from an AOF file. Errors that occur during propagation # are unexpected, and can cause data inconsistency. However, there are edge cases # in earlier versions of Redis where it was possible for the server to replicate or persist # commands that would fail on future versions. For this reason the default behavior # is to ignore such errors and continue processing commands. # # If an application wants to ensure there is no data divergence, this configuration # should be set to 'panic' instead. The value can also be set to 'panic-on-replicas' # to only panic when a replica encounters an error on the replication stream. One of # these two panic values will become the default value in the future once there are # sufficient safety mechanisms in place to prevent false positive crashes. # # propagation-error-behavior ignore # Replica ignore disk write errors controls the behavior of a replica when it is # unable to persist a write command received from its master to disk. By default, # this configuration is set to 'no' and will crash the replica in this condition. # It is not recommended to change this default, however in order to be compatible # with older versions of Redis this config can be toggled to 'yes' which will just # log a warning and execute the write command it got from the master. # # replica-ignore-disk-write-errors no # ----------------------------------------------------------------------------- # By default, Redis Sentinel includes all replicas in its reports. A replica # can be excluded from Redis Sentinel's announcements. An unannounced replica # will be ignored by the 'sentinel replicas ' command and won't be # exposed to Redis Sentinel's clients. # # This option does not change the behavior of replica-priority. Even with # replica-announced set to 'no', the replica can be promoted to master. To # prevent this behavior, set replica-priority to 0. # # replica-announced yes # It is possible for a master to stop accepting writes if there are less than # N replicas connected, having a lag less or equal than M seconds. # # The N replicas need to be in "online" state. # # The lag in seconds, that must be <= the specified value, is calculated from # the last ping received from the replica, that is usually sent every second. # # This option does not GUARANTEE that N replicas will accept the write, but # will limit the window of exposure for lost writes in case not enough replicas # are available, to the specified number of seconds. # # For example to require at least 3 replicas with a lag <= 10 seconds use: # # min-replicas-to-write 3 # min-replicas-max-lag 10 # # Setting one or the other to 0 disables the feature. # # By default min-replicas-to-write is set to 0 (feature disabled) and # min-replicas-max-lag is set to 10. # A Redis master is able to list the address and port of the attached # replicas in different ways. For example the "INFO replication" section # offers this information, which is used, among other tools, by # Redis Sentinel in order to discover replica instances. # Another place where this info is available is in the output of the # "ROLE" command of a master. # # The listed IP address and port normally reported by a replica is # obtained in the following way: # # IP: The address is auto detected by checking the peer address # of the socket used by the replica to connect with the master. # # Port: The port is communicated by the replica during the replication # handshake, and is normally the port that the replica is using to # listen for connections. # # However when port forwarding or Network Address Translation (NAT) is # used, the replica may actually be reachable via different IP and port # pairs. The following two options can be used by a replica in order to # report to its master a specific set of IP and port, so that both INFO # and ROLE will report those values. # # There is no need to use both the options if you need to override just # the port or the IP address. # # replica-announce-ip 5.5.5.5 # replica-announce-port 1234 ############################### KEYS TRACKING ################################# # Redis implements server assisted support for client side caching of values. # This is implemented using an invalidation table that remembers, using # a radix key indexed by key name, what clients have which keys. In turn # this is used in order to send invalidation messages to clients. Please # check this page to understand more about the feature: # # https://redis.io/docs/latest/develop/use/client-side-caching/ # # When tracking is enabled for a client, all the read only queries are assumed # to be cached: this will force Redis to store information in the invalidation # table. When keys are modified, such information is flushed away, and # invalidation messages are sent to the clients. However if the workload is # heavily dominated by reads, Redis could use more and more memory in order # to track the keys fetched by many clients. # # For this reason it is possible to configure a maximum fill value for the # invalidation table. By default it is set to 1M of keys, and once this limit # is reached, Redis will start to evict keys in the invalidation table # even if they were not modified, just to reclaim memory: this will in turn # force the clients to invalidate the cached values. Basically the table # maximum size is a trade off between the memory you want to spend server # side to track information about who cached what, and the ability of clients # to retain cached objects in memory. # # If you set the value to 0, it means there are no limits, and Redis will # retain as many keys as needed in the invalidation table. # In the "stats" INFO section, you can find information about the number of # keys in the invalidation table at every given moment. # # Note: when key tracking is used in broadcasting mode, no memory is used # in the server side so this setting is useless. # # tracking-table-max-keys 1000000 ################################## SECURITY ################################### # Warning: since Redis is pretty fast, an outside user can try up to # 1 million passwords per second against a modern box. This means that you # should use very strong passwords, otherwise they will be very easy to break. # Note that because the password is really a shared secret between the client # and the server, and should not be memorized by any human, the password # can be easily a long string from /dev/urandom or whatever, so by using a # long and unguessable password no brute force attack will be possible. # Redis ACL users are defined in the following format: # # user ... acl rules ... # # For example: # # user worker +@list +@connection ~jobs:* on >ffa9203c493aa99 # # The special username "default" is used for new connections. If this user # has the "nopass" rule, then new connections will be immediately authenticated # as the "default" user without the need of any password provided via the # AUTH command. Otherwise if the "default" user is not flagged with "nopass" # the connections will start in not authenticated state, and will require # AUTH (or the HELLO command AUTH option) in order to be authenticated and # start to work. # # The ACL rules that describe what a user can do are the following: # # on Enable the user: it is possible to authenticate as this user. # off Disable the user: it's no longer possible to authenticate # with this user, however the already authenticated connections # will still work. # skip-sanitize-payload RESTORE dump-payload sanitization is skipped. # sanitize-payload RESTORE dump-payload is sanitized (default). # + Allow the execution of that command. # May be used with `|` for allowing subcommands (e.g "+config|get") # - Disallow the execution of that command. # May be used with `|` for blocking subcommands (e.g "-config|set") # +@ Allow the execution of all the commands in such category # with valid categories are like @admin, @set, @sortedset, ... # and so forth, see the full list in the server.c file where # the Redis command table is described and defined. # The special category @all means all the commands, but currently # present in the server, and that will be loaded in the future # via modules. # +|first-arg Allow a specific first argument of an otherwise # disabled command. It is only supported on commands with # no sub-commands, and is not allowed as negative form # like -SELECT|1, only additive starting with "+". This # feature is deprecated and may be removed in the future. # allcommands Alias for +@all. Note that it implies the ability to execute # all the future commands loaded via the modules system. # nocommands Alias for -@all. # ~ Add a pattern of keys that can be mentioned as part of # commands. For instance ~* allows all the keys. The pattern # is a glob-style pattern like the one of KEYS. # It is possible to specify multiple patterns. # %R~ Add key read pattern that specifies which keys can be read # from. # %W~ Add key write pattern that specifies which keys can be # written to. # allkeys Alias for ~* # resetkeys Flush the list of allowed keys patterns. # & Add a glob-style pattern of Pub/Sub channels that can be # accessed by the user. It is possible to specify multiple channel # patterns. # allchannels Alias for &* # resetchannels Flush the list of allowed channel patterns. # > Add this password to the list of valid password for the user. # For example >mypass will add "mypass" to the list. # This directive clears the "nopass" flag (see later). # < Remove this password from the list of valid passwords. # nopass All the set passwords of the user are removed, and the user # is flagged as requiring no password: it means that every # pragma: allowlist secret # password will work against this user. If this directive is # used for the default user, every new connection will be # immediately authenticated with the default user without # any explicit AUTH command required. Note that the "resetpass" # directive will clear this condition. # resetpass Flush the list of allowed passwords. Moreover removes the # "nopass" status. After "resetpass" the user has no associated # passwords and there is no way to authenticate without adding # some password (or setting it as "nopass" later). # reset Performs the following actions: resetpass, resetkeys, resetchannels, # allchannels (if acl-pubsub-default is set), off, clearselectors, -@all. # The user returns to the same state it has immediately after its creation. # () Create a new selector with the options specified within the # parentheses and attach it to the user. Each option should be # space separated. The first character must be ( and the last # character must be ). # clearselectors Remove all of the currently attached selectors. # Note this does not change the "root" user permissions, # which are the permissions directly applied onto the # user (outside the parentheses). # # ACL rules can be specified in any order: for instance you can start with # passwords, then flags, or key patterns. However note that the additive # and subtractive rules will CHANGE MEANING depending on the ordering. # For instance see the following example: # # user alice on +@all -DEBUG ~* >somepassword # # This will allow "alice" to use all the commands with the exception of the # DEBUG command, since +@all added all the commands to the set of the commands # alice can use, and later DEBUG was removed. However if we invert the order # of two ACL rules the result will be different: # # user alice on -DEBUG +@all ~* >somepassword # # Now DEBUG was removed when alice had yet no commands in the set of allowed # commands, later all the commands are added, so the user will be able to # execute everything. # # Basically ACL rules are processed left-to-right. # # The following is a list of command categories and their meanings: # * keyspace - Writing or reading from keys, databases, or their metadata # in a type agnostic way. Includes DEL, RESTORE, DUMP, RENAME, EXISTS, DBSIZE, # KEYS, EXPIRE, TTL, FLUSHALL, etc. Commands that may modify the keyspace, # key or metadata will also have `write` category. Commands that only read # the keyspace, key or metadata will have the `read` category. # * read - Reading from keys (values or metadata). Note that commands that don't # interact with keys, will not have either `read` or `write`. # * write - Writing to keys (values or metadata) # * admin - Administrative commands. Normal applications will never need to use # these. Includes REPLICAOF, CONFIG, DEBUG, SAVE, MONITOR, ACL, SHUTDOWN, etc. # * dangerous - Potentially dangerous (each should be considered with care for # various reasons). This includes FLUSHALL, MIGRATE, RESTORE, SORT, KEYS, # CLIENT, DEBUG, INFO, CONFIG, SAVE, REPLICAOF, etc. # * connection - Commands affecting the connection or other connections. # This includes AUTH, SELECT, COMMAND, CLIENT, ECHO, PING, etc. # * blocking - Potentially blocking the connection until released by another # command. # * fast - Fast O(1) commands. May loop on the number of arguments, but not the # number of elements in the key. # * slow - All commands that are not Fast. # * pubsub - PUBLISH / SUBSCRIBE related # * transaction - WATCH / MULTI / EXEC related commands. # * scripting - Scripting related. # * set - Data type: sets related. # * sortedset - Data type: zsets related. # * list - Data type: lists related. # * hash - Data type: hashes related. # * string - Data type: strings related. # * bitmap - Data type: bitmaps related. # * hyperloglog - Data type: hyperloglog related. # * geo - Data type: geo related. # * stream - Data type: streams related. # # For more information about ACL configuration please refer to # the Redis web site at https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/ # ACL LOG # # The ACL Log tracks failed commands and authentication events associated # with ACLs. The ACL Log is useful to troubleshoot failed commands blocked # by ACLs. The ACL Log is stored in memory. You can reclaim memory with # ACL LOG RESET. Define the maximum entry length of the ACL Log below. acllog-max-len 128 # Using an external ACL file # # Instead of configuring users here in this file, it is possible to use # a stand-alone file just listing users. The two methods cannot be mixed: # if you configure users here and at the same time you activate the external # ACL file, the server will refuse to start. # # The format of the external ACL user file is exactly the same as the # format that is used inside redis.conf to describe users. # # aclfile /etc/redis/users.acl # IMPORTANT NOTE: starting with Redis 6 "requirepass" is just a compatibility # layer on top of the new ACL system. The option effect will be just setting # the password for the default user. Clients will still authenticate using # AUTH as usually, or more explicitly with AUTH default # if they follow the new protocol: both will work. # # The requirepass is not compatible with aclfile option and the ACL LOAD # command, these will cause requirepass to be ignored. # # requirepass foobared # New users are initialized with restrictive permissions by default, via the # equivalent of this ACL rule 'off resetkeys -@all'. Starting with Redis 6.2, it # is possible to manage access to Pub/Sub channels with ACL rules as well. The # default Pub/Sub channels permission if new users is controlled by the # acl-pubsub-default configuration directive, which accepts one of these values: # # allchannels: grants access to all Pub/Sub channels # resetchannels: revokes access to all Pub/Sub channels # # From Redis 7.0, acl-pubsub-default defaults to 'resetchannels' permission. # # acl-pubsub-default resetchannels # Command renaming (DEPRECATED). # # ------------------------------------------------------------------------ # WARNING: avoid using this option if possible. Instead use ACLs to remove # commands from the default user, and put them only in some admin user you # create for administrative purposes. # ------------------------------------------------------------------------ # # It is possible to change the name of dangerous commands in a shared # environment. For instance the CONFIG command may be renamed into something # hard to guess so that it will still be available for internal-use tools # but not available for general clients. # # Example: # # rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52 # # It is also possible to completely kill a command by renaming it into # an empty string: # # rename-command CONFIG "" # # Please note that changing the name of commands that are logged into the # AOF file or transmitted to replicas may cause problems. ################################### CLIENTS #################################### # Set the max number of connected clients at the same time. By default # this limit is set to 10000 clients, however if the Redis server is not # able to configure the process file limit to allow for the specified limit # the max number of allowed clients is set to the current file limit # minus 32 (as Redis reserves a few file descriptors for internal uses). # # Once the limit is reached Redis will close all the new connections sending # an error 'max number of clients reached'. # # IMPORTANT: When Redis Cluster is used, the max number of connections is also # shared with the cluster bus: every node in the cluster will use two # connections, one incoming and another outgoing. It is important to size the # limit accordingly in case of very large clusters. # # maxclients 10000 ############################## MEMORY MANAGEMENT ################################ # Set a memory usage limit to the specified amount of bytes. # When the memory limit is reached Redis will try to remove keys # according to the eviction policy selected (see maxmemory-policy). # # If Redis can't remove keys according to the policy, or if the policy is # set to 'noeviction', Redis will start to reply with errors to commands # that would use more memory, like SET, LPUSH, and so on, and will continue # to reply to read-only commands like GET. # # This option is usually useful when using Redis as an LRU or LFU cache, or to # set a hard memory limit for an instance (using the 'noeviction' policy). # # WARNING: If you have replicas attached to an instance with maxmemory on, # the size of the output buffers needed to feed the replicas are subtracted # from the used memory count, so that network problems / resyncs will # not trigger a loop where keys are evicted, and in turn the output # buffer of replicas is full with DELs of keys evicted triggering the deletion # of more keys, and so forth until the database is completely emptied. # # In short... if you have replicas attached it is suggested that you set a lower # limit for maxmemory so that there is some free RAM on the system for replica # output buffers (but this is not needed if the policy is 'noeviction'). # # maxmemory # MAXMEMORY POLICY: how Redis will select what to remove when maxmemory # is reached. You can select one from the following behaviors: # # volatile-lru -> Evict using approximated LRU, only keys with an expire set. # allkeys-lru -> Evict any key using approximated LRU. # volatile-lfu -> Evict using approximated LFU, only keys with an expire set. # allkeys-lfu -> Evict any key using approximated LFU. # volatile-random -> Remove a random key having an expire set. # allkeys-random -> Remove a random key, any key. # volatile-ttl -> Remove the key with the nearest expire time (minor TTL) # noeviction -> Don't evict anything, just return an error on write operations. # # LRU means Least Recently Used # LFU means Least Frequently Used # # Both LRU, LFU and volatile-ttl are implemented using approximated # randomized algorithms. # # Note: with any of the above policies, when there are no suitable keys for # eviction, Redis will return an error on write operations that require # more memory. These are usually commands that create new keys, add data or # modify existing keys. A few examples are: SET, INCR, HSET, LPUSH, SUNIONSTORE, # SORT (due to the STORE argument), and EXEC (if the transaction includes any # command that requires memory). # # The default is: # # maxmemory-policy noeviction # LRU, LFU and minimal TTL algorithms are not precise algorithms but approximated # algorithms (in order to save memory), so you can tune it for speed or # accuracy. By default Redis will check five keys and pick the one that was # used least recently, you can change the sample size using the following # configuration directive. # # The default of 5 produces good enough results. 10 Approximates very closely # true LRU but costs more CPU. 3 is faster but not very accurate. The maximum # value that can be set is 64. # # maxmemory-samples 5 # Eviction processing is designed to function well with the default setting. # If there is an unusually large amount of write traffic, this value may need to # be increased. Decreasing this value may reduce latency at the risk of # eviction processing effectiveness # 0 = minimum latency, 10 = default, 100 = process without regard to latency # # maxmemory-eviction-tenacity 10 # Starting from Redis 5, by default a replica will ignore its maxmemory setting # (unless it is promoted to master after a failover or manually). It means # that the eviction of keys will be just handled by the master, sending the # DEL commands to the replica as keys evict in the master side. # # This behavior ensures that masters and replicas stay consistent, and is usually # what you want, however if your replica is writable, or you want the replica # to have a different memory setting, and you are sure all the writes performed # to the replica are idempotent, then you may change this default (but be sure # to understand what you are doing). # # Note that since the replica by default does not evict, it may end using more # memory than the one set via maxmemory (there are certain buffers that may # be larger on the replica, or data structures may sometimes take more memory # and so forth). So make sure you monitor your replicas and make sure they # have enough memory to never hit a real out-of-memory condition before the # master hits the configured maxmemory setting. # # replica-ignore-maxmemory yes # Redis reclaims expired keys in two ways: upon access when those keys are # found to be expired, and also in background, in what is called the # "active expire key". The key space is slowly and interactively scanned # looking for expired keys to reclaim, so that it is possible to free memory # of keys that are expired and will never be accessed again in a short time. # # The default effort of the expire cycle will try to avoid having more than # ten percent of expired keys still in memory, and will try to avoid consuming # more than 25% of total memory and to add latency to the system. However # it is possible to increase the expire "effort" that is normally set to # "1", to a greater value, up to the value "10". At its maximum value the # system will use more CPU, longer cycles (and technically may introduce # more latency), and will tolerate less already expired keys still present # in the system. It's a tradeoff between memory, CPU and latency. # # active-expire-effort 1 ############################# LAZY FREEING #################################### # Redis has two primitives to delete keys. One is called DEL and is a blocking # deletion of the object. It means that the server stops processing new commands # in order to reclaim all the memory associated with an object in a synchronous # way. If the key deleted is associated with a small object, the time needed # in order to execute the DEL command is very small and comparable to most other # O(1) or O(log_N) commands in Redis. However if the key is associated with an # aggregated value containing millions of elements, the server can block for # a long time (even seconds) in order to complete the operation. # # For the above reasons Redis also offers non blocking deletion primitives # such as UNLINK (non blocking DEL) and the ASYNC option of FLUSHALL and # FLUSHDB commands, in order to reclaim memory in background. Those commands # are executed in constant time. Another thread will incrementally free the # object in the background as fast as possible. # # DEL, UNLINK and ASYNC option of FLUSHALL and FLUSHDB are user-controlled. # It's up to the design of the application to understand when it is a good # idea to use one or the other. However the Redis server sometimes has to # delete keys or flush the whole database as a side effect of other operations. # Specifically Redis deletes objects independently of a user call in the # following scenarios: # # 1) On eviction, because of the maxmemory and maxmemory policy configurations, # in order to make room for new data, without going over the specified # memory limit. # 2) Because of expire: when a key with an associated time to live (see the # EXPIRE command) must be deleted from memory. # 3) Because of a side effect of a command that stores data on a key that may # already exist. For example the RENAME command may delete the old key # content when it is replaced with another one. Similarly SUNIONSTORE # or SORT with STORE option may delete existing keys. The SET command # itself removes any old content of the specified key in order to replace # it with the specified string. # 4) During replication, when a replica performs a full resynchronization with # its master, the content of the whole database is removed in order to # load the RDB file just transferred. # # In all the above cases the default is to delete objects in a blocking way, # like if DEL was called. However you can configure each case specifically # in order to instead release memory in a non-blocking way like if UNLINK # was called, using the following configuration directives. lazyfree-lazy-eviction no lazyfree-lazy-expire no lazyfree-lazy-server-del no replica-lazy-flush no # It is also possible, for the case when to replace the user code DEL calls # with UNLINK calls is not easy, to modify the default behavior of the DEL # command to act exactly like UNLINK, using the following configuration # directive: lazyfree-lazy-user-del no # FLUSHDB, FLUSHALL, SCRIPT FLUSH and FUNCTION FLUSH support both asynchronous and synchronous # deletion, which can be controlled by passing the [SYNC|ASYNC] flags into the # commands. When neither flag is passed, this directive will be used to determine # if the data should be deleted asynchronously. lazyfree-lazy-user-flush no ################################ THREADED I/O ################################# # Redis is mostly single threaded, however there are certain threaded # operations such as UNLINK, slow I/O accesses and other things that are # performed on side threads. # # Now it is also possible to handle Redis clients socket reads and writes # in different I/O threads. Since especially writing is so slow, normally # Redis users use pipelining in order to speed up the Redis performances per # core, and spawn multiple instances in order to scale more. Using I/O # threads it is possible to easily speedup several times Redis without resorting # to pipelining nor sharding of the instance. # # By default threading is disabled, we suggest enabling it only in machines # that have at least 4 or more cores, leaving at least one spare core. # We also recommend using threaded I/O only if you actually have performance # problems, with Redis instances being able to use a quite big percentage of # CPU time, otherwise there is no point in using this feature. # # So for instance if you have a four cores boxes, try to use 3 I/O # threads, if you have a 8 cores, try to use 7 threads. In order to # enable I/O threads use the following configuration directive: # # io-threads 4 # # Setting io-threads to 1 will just use the main thread as usual. # When I/O threads are enabled, we not only use threads for writes, that # is to thread the write(2) syscall and transfer the client buffers to the # socket, but also use threads for reads and protocol parsing. # # NOTE: If you want to test the Redis speedup using redis-benchmark, make # sure you also run the benchmark itself in threaded mode, using the # --threads option to match the number of Redis threads, otherwise you'll not # be able to notice the improvements. ############################ KERNEL OOM CONTROL ############################## # On Linux, it is possible to hint the kernel OOM killer on what processes # should be killed first when out of memory. # # Enabling this feature makes Redis actively control the oom_score_adj value # for all its processes, depending on their role. The default scores will # attempt to have background child processes killed before all others, and # replicas killed before masters. # # Redis supports these options: # # no: Don't make changes to oom-score-adj (default). # yes: Alias to "relative" see below. # absolute: Values in oom-score-adj-values are written as is to the kernel. # relative: Values are used relative to the initial value of oom_score_adj when # the server starts and are then clamped to a range of -1000 to 1000. # Because typically the initial value is 0, they will often match the # absolute values. oom-score-adj no # When oom-score-adj is used, this directive controls the specific values used # for master, replica and background child processes. Values range -2000 to # 2000 (higher means more likely to be killed). # # Unprivileged processes (not root, and without CAP_SYS_RESOURCE capabilities) # can freely increase their value, but not decrease it below its initial # settings. This means that setting oom-score-adj to "relative" and setting the # oom-score-adj-values to positive values will always succeed. oom-score-adj-values 0 200 800 #################### KERNEL transparent hugepage CONTROL ###################### # Usually the kernel Transparent Huge Pages control is set to "madvise" or # "never" by default (/sys/kernel/mm/transparent_hugepage/enabled), in which # case this config has no effect. On systems in which it is set to "always", # redis will attempt to disable it specifically for the redis process in order # to avoid latency problems specifically with fork(2) and CoW. # If for some reason you prefer to keep it enabled, you can set this config to # "no" and the kernel global to "always". disable-thp yes ############################## APPEND ONLY MODE ############################### # By default Redis asynchronously dumps the dataset on disk. This mode is # good enough in many applications, but an issue with the Redis process or # a power outage may result into a few minutes of writes lost (depending on # the configured save points). # # The Append Only File is an alternative persistence mode that provides # much better durability. For instance using the default data fsync policy # (see later in the config file) Redis can lose just one second of writes in a # dramatic event like a server power outage, or a single write if something # wrong with the Redis process itself happens, but the operating system is # still running correctly. # # AOF and RDB persistence can be enabled at the same time without problems. # If the AOF is enabled on startup Redis will load the AOF, that is the file # with the better durability guarantees. # # Note that changing this value in a config file of an existing database and # restarting the server can lead to data loss. A conversion needs to be done # by setting it via CONFIG command on a live server first. # # Please check https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/ for more information. appendonly no # The base name of the append only file. # # Redis 7 and newer use a set of append-only files to persist the dataset # and changes applied to it. There are two basic types of files in use: # # - Base files, which are a snapshot representing the complete state of the # dataset at the time the file was created. Base files can be either in # the form of RDB (binary serialized) or AOF (textual commands). # - Incremental files, which contain additional commands that were applied # to the dataset following the previous file. # # In addition, manifest files are used to track the files and the order in # which they were created and should be applied. # # Append-only file names are created by Redis following a specific pattern. # The file name's prefix is based on the 'appendfilename' configuration # parameter, followed by additional information about the sequence and type. # # For example, if appendfilename is set to appendonly.aof, the following file # names could be derived: # # - appendonly.aof.1.base.rdb as a base file. # - appendonly.aof.1.incr.aof, appendonly.aof.2.incr.aof as incremental files. # - appendonly.aof.manifest as a manifest file. appendfilename "appendonly.aof" # For convenience, Redis stores all persistent append-only files in a dedicated # directory. The name of the directory is determined by the appenddirname # configuration parameter. appenddirname "appendonlydir" # The fsync() call tells the Operating System to actually write data on disk # instead of waiting for more data in the output buffer. Some OS will really flush # data on disk, some other OS will just try to do it ASAP. # # Redis supports three different modes: # # no: don't fsync, just let the OS flush the data when it wants. Faster. # always: fsync after every write to the append only log. Slow, Safest. # everysec: fsync only one time every second. Compromise. # # The default is "everysec", as that's usually the right compromise between # speed and data safety. It's up to you to understand if you can relax this to # "no" that will let the operating system flush the output buffer when # it wants, for better performances (but if you can live with the idea of # some data loss consider the default persistence mode that's snapshotting), # or on the contrary, use "always" that's very slow but a bit safer than # everysec. # # More details please check the following article: # http://antirez.com/post/redis-persistence-demystified.html # # If unsure, use "everysec". # appendfsync always appendfsync everysec # appendfsync no # When the AOF fsync policy is set to always or everysec, and a background # saving process (a background save or AOF log background rewriting) is # performing a lot of I/O against the disk, in some Linux configurations # Redis may block too long on the fsync() call. Note that there is no fix for # this currently, as even performing fsync in a different thread will block # our synchronous write(2) call. # # In order to mitigate this problem it's possible to use the following option # that will prevent fsync() from being called in the main process while a # BGSAVE or BGREWRITEAOF is in progress. # # This means that while another child is saving, the durability of Redis is # the same as "appendfsync no". In practical terms, this means that it is # possible to lose up to 30 seconds of log in the worst scenario (with the # default Linux settings). # # If you have latency problems turn this to "yes". Otherwise leave it as # "no" that is the safest pick from the point of view of durability. no-appendfsync-on-rewrite no # Automatic rewrite of the append only file. # Redis is able to automatically rewrite the log file implicitly calling # BGREWRITEAOF when the AOF log size grows by the specified percentage. # # This is how it works: Redis remembers the size of the AOF file after the # latest rewrite (if no rewrite has happened since the restart, the size of # the AOF at startup is used). # # This base size is compared to the current size. If the current size is # bigger than the specified percentage, the rewrite is triggered. Also # you need to specify a minimal size for the AOF file to be rewritten, this # is useful to avoid rewriting the AOF file even if the percentage increase # is reached but it is still pretty small. # # Specify a percentage of zero in order to disable the automatic AOF # rewrite feature. auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb # An AOF file may be found to be truncated at the end during the Redis # startup process, when the AOF data gets loaded back into memory. # This may happen when the system where Redis is running # crashes, especially when an ext4 filesystem is mounted without the # data=ordered option (however this can't happen when Redis itself # crashes or aborts but the operating system still works correctly). # # Redis can either exit with an error when this happens, or load as much # data as possible (the default now) and start if the AOF file is found # to be truncated at the end. The following option controls this behavior. # # If aof-load-truncated is set to yes, a truncated AOF file is loaded and # the Redis server starts emitting a log to inform the user of the event. # Otherwise if the option is set to no, the server aborts with an error # and refuses to start. When the option is set to no, the user requires # to fix the AOF file using the "redis-check-aof" utility before to restart # the server. # # Note that if the AOF file will be found to be corrupted in the middle # the server will still exit with an error. This option only applies when # Redis will try to read more data from the AOF file but not enough bytes # will be found. aof-load-truncated yes # Redis can create append-only base files in either RDB or AOF formats. Using # the RDB format is always faster and more efficient, and disabling it is only # supported for backward compatibility purposes. aof-use-rdb-preamble yes # Redis supports recording timestamp annotations in the AOF to support restoring # the data from a specific point-in-time. However, using this capability changes # the AOF format in a way that may not be compatible with existing AOF parsers. aof-timestamp-enabled no ################################ SHUTDOWN ##################################### # Maximum time to wait for replicas when shutting down, in seconds. # # During shut down, a grace period allows any lagging replicas to catch up with # the latest replication offset before the master exists. This period can # prevent data loss, especially for deployments without configured disk backups. # # The 'shutdown-timeout' value is the grace period's duration in seconds. It is # only applicable when the instance has replicas. To disable the feature, set # the value to 0. # # shutdown-timeout 10 # When Redis receives a SIGINT or SIGTERM, shutdown is initiated and by default # an RDB snapshot is written to disk in a blocking operation if save points are configured. # The options used on signaled shutdown can include the following values: # default: Saves RDB snapshot only if save points are configured. # Waits for lagging replicas to catch up. # save: Forces a DB saving operation even if no save points are configured. # nosave: Prevents DB saving operation even if one or more save points are configured. # now: Skips waiting for lagging replicas. # force: Ignores any errors that would normally prevent the server from exiting. # # Any combination of values is allowed as long as "save" and "nosave" are not set simultaneously. # Example: "nosave force now" # # shutdown-on-sigint default # shutdown-on-sigterm default ################ NON-DETERMINISTIC LONG BLOCKING COMMANDS ##################### # Maximum time in milliseconds for EVAL scripts, functions and in some cases # modules' commands before Redis can start processing or rejecting other clients. # # If the maximum execution time is reached Redis will start to reply to most # commands with a BUSY error. # # In this state Redis will only allow a handful of commands to be executed. # For instance, SCRIPT KILL, FUNCTION KILL, SHUTDOWN NOSAVE and possibly some # module specific 'allow-busy' commands. # # SCRIPT KILL and FUNCTION KILL will only be able to stop a script that did not # yet call any write commands, so SHUTDOWN NOSAVE may be the only way to stop # the server in the case a write command was already issued by the script when # the user doesn't want to wait for the natural termination of the script. # # The default is 5 seconds. It is possible to set it to 0 or a negative value # to disable this mechanism (uninterrupted execution). Note that in the past # this config had a different name, which is now an alias, so both of these do # the same: # lua-time-limit 5000 # busy-reply-threshold 5000 ################################ REDIS CLUSTER ############################### # Normal Redis instances can't be part of a Redis Cluster; only nodes that are # started as cluster nodes can. In order to start a Redis instance as a # cluster node enable the cluster support uncommenting the following: # # cluster-enabled yes # Every cluster node has a cluster configuration file. This file is not # intended to be edited by hand. It is created and updated by Redis nodes. # Every Redis Cluster node requires a different cluster configuration file. # Make sure that instances running in the same system do not have # overlapping cluster configuration file names. # # cluster-config-file nodes-6379.conf # Cluster node timeout is the amount of milliseconds a node must be unreachable # for it to be considered in failure state. # Most other internal time limits are a multiple of the node timeout. # # cluster-node-timeout 15000 # The cluster port is the port that the cluster bus will listen for inbound connections on. When set # to the default value, 0, it will be bound to the command port + 10000. Setting this value requires # you to specify the cluster bus port when executing cluster meet. # cluster-port 0 # A replica of a failing master will avoid to start a failover if its data # looks too old. # # There is no simple way for a replica to actually have an exact measure of # its "data age", so the following two checks are performed: # # 1) If there are multiple replicas able to failover, they exchange messages # in order to try to give an advantage to the replica with the best # replication offset (more data from the master processed). # Replicas will try to get their rank by offset, and apply to the start # of the failover a delay proportional to their rank. # # 2) Every single replica computes the time of the last interaction with # its master. This can be the last ping or command received (if the master # is still in the "connected" state), or the time that elapsed since the # disconnection with the master (if the replication link is currently down). # If the last interaction is too old, the replica will not try to failover # at all. # # The point "2" can be tuned by user. Specifically a replica will not perform # the failover if, since the last interaction with the master, the time # elapsed is greater than: # # (node-timeout * cluster-replica-validity-factor) + repl-ping-replica-period # # So for example if node-timeout is 30 seconds, and the cluster-replica-validity-factor # is 10, and assuming a default repl-ping-replica-period of 10 seconds, the # replica will not try to failover if it was not able to talk with the master # for longer than 310 seconds. # # A large cluster-replica-validity-factor may allow replicas with too old data to failover # a master, while a too small value may prevent the cluster from being able to # elect a replica at all. # # For maximum availability, it is possible to set the cluster-replica-validity-factor # to a value of 0, which means, that replicas will always try to failover the # master regardless of the last time they interacted with the master. # (However they'll always try to apply a delay proportional to their # offset rank). # # Zero is the only value able to guarantee that when all the partitions heal # the cluster will always be able to continue. # # cluster-replica-validity-factor 10 # Cluster replicas are able to migrate to orphaned masters, that are masters # that are left without working replicas. This improves the cluster ability # to resist to failures as otherwise an orphaned master can't be failed over # in case of failure if it has no working replicas. # # Replicas migrate to orphaned masters only if there are still at least a # given number of other working replicas for their old master. This number # is the "migration barrier". A migration barrier of 1 means that a replica # will migrate only if there is at least 1 other working replica for its master # and so forth. It usually reflects the number of replicas you want for every # master in your cluster. # # Default is 1 (replicas migrate only if their masters remain with at least # one replica). To disable migration just set it to a very large value or # set cluster-allow-replica-migration to 'no'. # A value of 0 can be set but is useful only for debugging and dangerous # in production. # # cluster-migration-barrier 1 # Turning off this option allows to use less automatic cluster configuration. # It both disables migration to orphaned masters and migration from masters # that became empty. # # Default is 'yes' (allow automatic migrations). # # cluster-allow-replica-migration yes # By default Redis Cluster nodes stop accepting queries if they detect there # is at least a hash slot uncovered (no available node is serving it). # This way if the cluster is partially down (for example a range of hash slots # are no longer covered) all the cluster becomes, eventually, unavailable. # It automatically returns available as soon as all the slots are covered again. # # However sometimes you want the subset of the cluster which is working, # to continue to accept queries for the part of the key space that is still # covered. In order to do so, just set the cluster-require-full-coverage # option to no. # # cluster-require-full-coverage yes # This option, when set to yes, prevents replicas from trying to failover its # master during master failures. However the replica can still perform a # manual failover, if forced to do so. # # This is useful in different scenarios, especially in the case of multiple # data center operations, where we want one side to never be promoted if not # in the case of a total DC failure. # # cluster-replica-no-failover no # This option, when set to yes, allows nodes to serve read traffic while the # cluster is in a down state, as long as it believes it owns the slots. # # This is useful for two cases. The first case is for when an application # doesn't require consistency of data during node failures or network partitions. # One example of this is a cache, where as long as the node has the data it # should be able to serve it. # # The second use case is for configurations that don't meet the recommended # three shards but want to enable cluster mode and scale later. A # master outage in a 1 or 2 shard configuration causes a read/write outage to the # entire cluster without this option set, with it set there is only a write outage. # Without a quorum of masters, slot ownership will not change automatically. # # cluster-allow-reads-when-down no # This option, when set to yes, allows nodes to serve pubsub shard traffic while # the cluster is in a down state, as long as it believes it owns the slots. # # This is useful if the application would like to use the pubsub feature even when # the cluster global stable state is not OK. If the application wants to make sure only # one shard is serving a given channel, this feature should be kept as yes. # # cluster-allow-pubsubshard-when-down yes # Cluster link send buffer limit is the limit on the memory usage of an individual # cluster bus link's send buffer in bytes. Cluster links would be freed if they exceed # this limit. This is to primarily prevent send buffers from growing unbounded on links # toward slow peers (E.g. PubSub messages being piled up). # This limit is disabled by default. Enable this limit when 'mem_cluster_links' INFO field # and/or 'send-buffer-allocated' entries in the 'CLUSTER LINKS` command output continuously increase. # Minimum limit of 1gb is recommended so that cluster link buffer can fit in at least a single # PubSub message by default. (client-query-buffer-limit default value is 1gb) # # cluster-link-sendbuf-limit 0 # Clusters can configure their announced hostname using this config. This is a common use case for # applications that need to use TLS Server Name Indication (SNI) or dealing with DNS based # routing. By default this value is only shown as additional metadata in the CLUSTER SLOTS # command, but can be changed using 'cluster-preferred-endpoint-type' config. This value is # communicated along the clusterbus to all nodes, setting it to an empty string will remove # the hostname and also propagate the removal. # # cluster-announce-hostname "" # Clusters can configure an optional nodename to be used in addition to the node ID for # debugging and admin information. This name is broadcasted between nodes, so will be used # in addition to the node ID when reporting cross node events such as node failures. # cluster-announce-human-nodename "" # Clusters can advertise how clients should connect to them using either their IP address, # a user defined hostname, or by declaring they have no endpoint. Which endpoint is # shown as the preferred endpoint is set by using the cluster-preferred-endpoint-type # config with values 'ip', 'hostname', or 'unknown-endpoint'. This value controls how # the endpoint returned for MOVED/ASKING requests as well as the first field of CLUSTER SLOTS. # If the preferred endpoint type is set to hostname, but no announced hostname is set, a '?' # will be returned instead. # # When a cluster advertises itself as having an unknown endpoint, it's indicating that # the server doesn't know how clients can reach the cluster. This can happen in certain # networking situations where there are multiple possible routes to the node, and the # server doesn't know which one the client took. In this case, the server is expecting # the client to reach out on the same endpoint it used for making the last request, but use # the port provided in the response. # # cluster-preferred-endpoint-type ip # This configuration defines the sampling ratio (0-100) for checking command # compatibility in cluster mode. When a command is executed, it is sampled at # the specified ratio to determine if it complies with Redis cluster constraints, # such as cross-slot restrictions. # # - A value of 0 means no commands are sampled for compatibility checks. # - A value of 100 means all commands are checked. # - Intermediate values (e.g., 10) mean that approximately 10% of the commands # are randomly selected for compatibility verification. # # Higher sampling ratios may introduce additional performance overhead, especially # under high QPS. The default value is 0 (no sampling). # # cluster-compatibility-sample-ratio 0 # Clusters can be configured to track per-slot resource statistics, # which are accessible by the CLUSTER SLOT-STATS command. # # By default, the 'cluster-slot-stats-enabled' is disabled, and only 'key-count' is captured. # By enabling the 'cluster-slot-stats-enabled' config, the cluster will begin to capture advanced statistics. # These statistics can be leveraged to assess general slot usage trends, identify hot / cold slots, # migrate slots for a balanced cluster workload, and / or re-write application logic to better utilize slots. # # cluster-slot-stats-enabled no # In order to setup your cluster make sure to read the documentation # available at https://redis.io web site. ########################## CLUSTER DOCKER/NAT support ######################## # In certain deployments, Redis Cluster nodes address discovery fails, because # addresses are NAT-ted or because ports are forwarded (the typical case is # Docker and other containers). # # In order to make Redis Cluster working in such environments, a static # configuration where each node knows its public address is needed. The # following four options are used for this scope, and are: # # * cluster-announce-ip # * cluster-announce-port # * cluster-announce-tls-port # * cluster-announce-bus-port # # Each instructs the node about its address, client ports (for connections # without and with TLS) and cluster message bus port. The information is then # published in the header of the bus packets so that other nodes will be able to # correctly map the address of the node publishing the information. # # If tls-cluster is set to yes and cluster-announce-tls-port is omitted or set # to zero, then cluster-announce-port refers to the TLS port. Note also that # cluster-announce-tls-port has no effect if tls-cluster is set to no. # # If the above options are not used, the normal Redis Cluster auto-detection # will be used instead. # # Note that when remapped, the bus port may not be at the fixed offset of # clients port + 10000, so you can specify any port and bus-port depending # on how they get remapped. If the bus-port is not set, a fixed offset of # 10000 will be used as usual. # # Example: # # cluster-announce-ip 10.1.1.5 # cluster-announce-tls-port 6379 # cluster-announce-port 0 # cluster-announce-bus-port 6380 ################################## SLOW LOG ################################### # The Redis Slow Log is a system to log queries that exceeded a specified # execution time. The execution time does not include the I/O operations # like talking with the client, sending the reply and so forth, # but just the time needed to actually execute the command (this is the only # stage of command execution where the thread is blocked and can not serve # other requests in the meantime). # # You can configure the slow log with two parameters: one tells Redis # what is the execution time, in microseconds, to exceed in order for the # command to get logged, and the other parameter is the length of the # slow log. When a new command is logged the oldest one is removed from the # queue of logged commands. # The following time is expressed in microseconds, so 1000000 is equivalent # to one second. Note that a negative number disables the slow log, while # a value of zero forces the logging of every command. slowlog-log-slower-than 10000 # There is no limit to this length. Just be aware that it will consume memory. # You can reclaim memory used by the slow log with SLOWLOG RESET. slowlog-max-len 128 ################################ LATENCY MONITOR ############################## # The Redis latency monitoring subsystem samples different operations # at runtime in order to collect data related to possible sources of # latency of a Redis instance. # # Via the LATENCY command this information is available to the user that can # print graphs and obtain reports. # # The system only logs operations that were performed in a time equal or # greater than the amount of milliseconds specified via the # latency-monitor-threshold configuration directive. When its value is set # to zero, the latency monitor is turned off. # # By default latency monitoring is disabled since it is mostly not needed # if you don't have latency issues, and collecting data has a performance # impact, that while very small, can be measured under big load. Latency # monitoring can easily be enabled at runtime using the command # "CONFIG SET latency-monitor-threshold " if needed. latency-monitor-threshold 0 ################################ LATENCY TRACKING ############################## # The Redis extended latency monitoring tracks the per command latencies and enables # exporting the percentile distribution via the INFO latencystats command, # and cumulative latency distributions (histograms) via the LATENCY command. # # By default, the extended latency monitoring is enabled since the overhead # of keeping track of the command latency is very small. # latency-tracking yes # By default the exported latency percentiles via the INFO latencystats command # are the p50, p99, and p999. # latency-tracking-info-percentiles 50 99 99.9 ############################# EVENT NOTIFICATION ############################## # Redis can notify Pub/Sub clients about events happening in the key space. # This feature is documented at https://redis.io/docs/latest/develop/use/keyspace-notifications/ # # For instance if keyspace events notification is enabled, and a client # performs a DEL operation on key "foo" stored in the Database 0, two # messages will be published via Pub/Sub: # # PUBLISH __keyspace@0__:foo del # PUBLISH __keyevent@0__:del foo # # It is possible to select the events that Redis will notify among a set # of classes. Every class is identified by a single character: # # K Keyspace events, published with __keyspace@__ prefix. # E Keyevent events, published with __keyevent@__ prefix. # g Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ... # $ String commands # l List commands # s Set commands # h Hash commands # z Sorted set commands # x Expired events (events generated every time a key expires) # e Evicted events (events generated when a key is evicted for maxmemory) # n New key events (Note: not included in the 'A' class) # t Stream commands # d Module key type events # m Key-miss events (Note: It is not included in the 'A' class) # o Overwritten events generated every time a key is overwritten. # (Note: not included in the 'A' class) # c Type-changed events generated every time a key's type changes # (Note: not included in the 'A' class) # A Alias for g$lshzxetd, so that the "AKE" string means all the events # except key-miss, new key, overwritten and type-changed. # # The "notify-keyspace-events" takes as argument a string that is composed # of zero or multiple characters. The empty string means that notifications # are disabled. # # Example: to enable list and generic events, from the point of view of the # event name, use: # # notify-keyspace-events Elg # # Example 2: to get the stream of the expired keys subscribing to channel # name __keyevent@0__:expired use: # # notify-keyspace-events Ex # # By default all notifications are disabled because most users don't need # this feature and the feature has some overhead. Note that if you don't # specify at least one of K or E, no events will be delivered. notify-keyspace-events "" ############################### ADVANCED CONFIG ############################### # Hashes are encoded using a memory efficient data structure when they have a # small number of entries, and the biggest entry does not exceed a given # threshold. These thresholds can be configured using the following directives. hash-max-listpack-entries 512 hash-max-listpack-value 64 # Lists are also encoded in a special way to save a lot of space. # The number of entries allowed per internal list node can be specified # as a fixed maximum size or a maximum number of elements. # For a fixed maximum size, use -5 through -1, meaning: # -5: max size: 64 Kb <-- not recommended for normal workloads # -4: max size: 32 Kb <-- not recommended # -3: max size: 16 Kb <-- probably not recommended # -2: max size: 8 Kb <-- good # -1: max size: 4 Kb <-- good # Positive numbers mean store up to _exactly_ that number of elements # per list node. # The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size), # but if your use case is unique, adjust the settings as necessary. list-max-listpack-size -2 # Lists may also be compressed. # Compress depth is the number of quicklist ziplist nodes from *each* side of # the list to *exclude* from compression. The head and tail of the list # are always uncompressed for fast push/pop operations. Settings are: # 0: disable all list compression # 1: depth 1 means "don't start compressing until after 1 node into the list, # going from either the head or tail" # So: [head]->node->node->...->node->[tail] # [head], [tail] will always be uncompressed; inner nodes will compress. # 2: [head]->[next]->node->node->...->node->[prev]->[tail] # 2 here means: don't compress head or head->next or tail->prev or tail, # but compress all nodes between them. # 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail] # etc. list-compress-depth 0 # Sets have a special encoding when a set is composed # of just strings that happen to be integers in radix 10 in the range # of 64 bit signed integers. # The following configuration setting sets the limit in the size of the # set in order to use this special memory saving encoding. set-max-intset-entries 512 # Sets containing non-integer values are also encoded using a memory efficient # data structure when they have a small number of entries, and the biggest entry # does not exceed a given threshold. These thresholds can be configured using # the following directives. set-max-listpack-entries 128 set-max-listpack-value 64 # Similarly to hashes and lists, sorted sets are also specially encoded in # order to save a lot of space. This encoding is only used when the length and # elements of a sorted set are below the following limits: zset-max-listpack-entries 128 zset-max-listpack-value 64 # HyperLogLog sparse representation bytes limit. The limit includes the # 16 bytes header. When a HyperLogLog using the sparse representation crosses # this limit, it is converted into the dense representation. # # A value greater than 16000 is totally useless, since at that point the # dense representation is more memory efficient. # # The suggested value is ~ 3000 in order to have the benefits of # the space efficient encoding without slowing down too much PFADD, # which is O(N) with the sparse encoding. The value can be raised to # ~ 10000 when CPU is not a concern, but space is, and the data set is # composed of many HyperLogLogs with cardinality in the 0 - 15000 range. hll-sparse-max-bytes 3000 # Streams macro node max size / items. The stream data structure is a radix # tree of big nodes that encode multiple items inside. Using this configuration # it is possible to configure how big a single node can be in bytes, and the # maximum number of items it may contain before switching to a new node when # appending new stream entries. If any of the following settings are set to # zero, the limit is ignored, so for instance it is possible to set just a # max entries limit by setting max-bytes to 0 and max-entries to the desired # value. stream-node-max-bytes 4096 stream-node-max-entries 100 # Active rehashing uses 1 millisecond every 100 milliseconds of CPU time in # order to help rehashing the main Redis hash table (the one mapping top-level # keys to values). The hash table implementation Redis uses (see dict.c) # performs a lazy rehashing: the more operation you run into a hash table # that is rehashing, the more rehashing "steps" are performed, so if the # server is idle the rehashing is never complete and some more memory is used # by the hash table. # # The default is to use this millisecond 10 times every second in order to # actively rehash the main dictionaries, freeing memory when possible. # # If unsure: # use "activerehashing no" if you have hard latency requirements and it is # not a good thing in your environment that Redis can reply from time to time # to queries with 2 milliseconds delay. # # use "activerehashing yes" if you don't have such hard requirements but # want to free memory asap when possible. activerehashing yes # The client output buffer limits can be used to force disconnection of clients # that are not reading data from the server fast enough for some reason (a # common reason is that a Pub/Sub client can't consume messages as fast as the # publisher can produce them). # # The limit can be set differently for the three different classes of clients: # # normal -> normal clients including MONITOR clients # replica -> replica clients # pubsub -> clients subscribed to at least one pubsub channel or pattern # # The syntax of every client-output-buffer-limit directive is the following: # # client-output-buffer-limit # # A client is immediately disconnected once the hard limit is reached, or if # the soft limit is reached and remains reached for the specified number of # seconds (continuously). # So for instance if the hard limit is 32 megabytes and the soft limit is # 16 megabytes / 10 seconds, the client will get disconnected immediately # if the size of the output buffers reach 32 megabytes, but will also get # disconnected if the client reaches 16 megabytes and continuously overcomes # the limit for 10 seconds. # # By default normal clients are not limited because they don't receive data # without asking (in a push way), but just after a request, so only # asynchronous clients may create a scenario where data is requested faster # than it can read. # # Instead there is a default limit for pubsub and replica clients, since # subscribers and replicas receive data in a push fashion. # # Note that it doesn't make sense to set the replica clients output buffer # limit lower than the repl-backlog-size config (partial sync will succeed # and then replica will get disconnected). # Such a configuration is ignored (the size of repl-backlog-size will be used). # This doesn't have memory consumption implications since the replica client # will share the backlog buffers memory. # # Both the hard or the soft limit can be disabled by setting them to zero. client-output-buffer-limit normal 0 0 0 client-output-buffer-limit replica 256mb 64mb 60 client-output-buffer-limit pubsub 32mb 8mb 60 # Client query buffers accumulate new commands. They are limited to a fixed # amount by default in order to avoid that a protocol desynchronization (for # instance due to a bug in the client) will lead to unbound memory usage in # the query buffer. However you can configure it here if you have very special # needs, such as a command with huge argument, or huge multi/exec requests or alike. # # client-query-buffer-limit 1gb # In some scenarios client connections can hog up memory leading to OOM # errors or data eviction. To avoid this we can cap the accumulated memory # used by all client connections (all pubsub and normal clients). Once we # reach that limit connections will be dropped by the server freeing up # memory. The server will attempt to drop the connections using the most # memory first. We call this mechanism "client eviction". # # Client eviction is configured using the maxmemory-clients setting as follows: # 0 - client eviction is disabled (default) # # A memory value can be used for the client eviction threshold, # for example: # maxmemory-clients 1g # # A percentage value (between 1% and 100%) means the client eviction threshold # is based on a percentage of the maxmemory setting. For example to set client # eviction at 5% of maxmemory: # maxmemory-clients 5% # In the Redis protocol, bulk requests, that are, elements representing single # strings, are normally limited to 512 mb. However you can change this limit # here, but must be 1mb or greater # # proto-max-bulk-len 512mb # Redis calls an internal function to perform many background tasks, like # closing connections of clients in timeout, purging expired keys that are # never requested, and so forth. # # Not all tasks are performed with the same frequency, but Redis checks for # tasks to perform according to the specified "hz" value. # # By default "hz" is set to 10. Raising the value will use more CPU when # Redis is idle, but at the same time will make Redis more responsive when # there are many keys expiring at the same time, and timeouts may be # handled with more precision. # # The range is between 1 and 500, however a value over 100 is usually not # a good idea. Most users should use the default of 10 and raise this up to # 100 only in environments where very low latency is required. hz 10 # Normally it is useful to have an HZ value which is proportional to the # number of clients connected. This is useful in order, for instance, to # avoid too many clients are processed for each background task invocation # in order to avoid latency spikes. # # Since the default HZ value by default is conservatively set to 10, Redis # offers, and enables by default, the ability to use an adaptive HZ value # which will temporarily raise when there are many connected clients. # # When dynamic HZ is enabled, the actual configured HZ will be used # as a baseline, but multiples of the configured HZ value will be actually # used as needed once more clients are connected. In this way an idle # instance will use very little CPU time while a busy instance will be # more responsive. dynamic-hz yes # When a child rewrites the AOF file, if the following option is enabled # the file will be fsync-ed every 4 MB of data generated. This is useful # in order to commit the file to the disk more incrementally and avoid # big latency spikes. aof-rewrite-incremental-fsync yes # When redis saves RDB file, if the following option is enabled # the file will be fsync-ed every 4 MB of data generated. This is useful # in order to commit the file to the disk more incrementally and avoid # big latency spikes. rdb-save-incremental-fsync yes # Redis LFU eviction (see maxmemory setting) can be tuned. However it is a good # idea to start with the default settings and only change them after investigating # how to improve the performances and how the keys LFU change over time, which # is possible to inspect via the OBJECT FREQ command. # # There are two tunable parameters in the Redis LFU implementation: the # counter logarithm factor and the counter decay time. It is important to # understand what the two parameters mean before changing them. # # The LFU counter is just 8 bits per key, it's maximum value is 255, so Redis # uses a probabilistic increment with logarithmic behavior. Given the value # of the old counter, when a key is accessed, the counter is incremented in # this way: # # 1. A random number R between 0 and 1 is extracted. # 2. A probability P is calculated as 1/(old_value*lfu_log_factor+1). # 3. The counter is incremented only if R < P. # # The default lfu-log-factor is 10. This is a table of how the frequency # counter changes with a different number of accesses with different # logarithmic factors: # # +--------+------------+------------+------------+------------+------------+ # | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits | # +--------+------------+------------+------------+------------+------------+ # | 0 | 104 | 255 | 255 | 255 | 255 | # +--------+------------+------------+------------+------------+------------+ # | 1 | 18 | 49 | 255 | 255 | 255 | # +--------+------------+------------+------------+------------+------------+ # | 10 | 10 | 18 | 142 | 255 | 255 | # +--------+------------+------------+------------+------------+------------+ # | 100 | 8 | 11 | 49 | 143 | 255 | # +--------+------------+------------+------------+------------+------------+ # # NOTE: The above table was obtained by running the following commands: # # redis-benchmark -n 1000000 incr foo # redis-cli object freq foo # # NOTE 2: The counter initial value is 5 in order to give new objects a chance # to accumulate hits. # # The counter decay time is the time, in minutes, that must elapse in order # for the key counter to be decremented. # # The default value for the lfu-decay-time is 1. A special value of 0 means we # will never decay the counter. # # lfu-log-factor 10 # lfu-decay-time 1 # The maximum number of new client connections accepted per event-loop cycle. This configuration # is set independently for TLS connections. # # By default, up to 10 new connection will be accepted per event-loop cycle for normal connections # and up to 1 new connection per event-loop cycle for TLS connections. # # Adjusting this to a larger number can slightly improve efficiency for new connections # at the risk of causing timeouts for regular commands on established connections. It is # not advised to change this without ensuring that all clients have limited connection # pools and exponential backoff in the case of command/connection timeouts. # # If your application is establishing a large number of new connections per second you should # also consider tuning the value of tcp-backlog, which allows the kernel to buffer more # pending connections before dropping or rejecting connections. # # max-new-connections-per-cycle 10 # max-new-tls-connections-per-cycle 1 ########################### ACTIVE DEFRAGMENTATION ####################### # # What is active defragmentation? # ------------------------------- # # Active (online) defragmentation allows a Redis server to compact the # spaces left between small allocations and deallocations of data in memory, # thus allowing to reclaim back memory. # # Fragmentation is a natural process that happens with every allocator (but # less so with Jemalloc, fortunately) and certain workloads. Normally a server # restart is needed in order to lower the fragmentation, or at least to flush # away all the data and create it again. However thanks to this feature # implemented by Oran Agra for Redis 4.0 this process can happen at runtime # in a "hot" way, while the server is running. # # Basically when the fragmentation is over a certain level (see the # configuration options below) Redis will start to create new copies of the # values in contiguous memory regions by exploiting certain specific Jemalloc # features (in order to understand if an allocation is causing fragmentation # and to allocate it in a better place), and at the same time, will release the # old copies of the data. This process, repeated incrementally for all the keys # will cause the fragmentation to drop back to normal values. # # Important things to understand: # # 1. This feature is disabled by default, and only works if you compiled Redis # to use the copy of Jemalloc we ship with the source code of Redis. # This is the default with Linux builds. # # 2. You never need to enable this feature if you don't have fragmentation # issues. # # 3. Once you experience fragmentation, you can enable this feature when # needed with the command "CONFIG SET activedefrag yes". # # The configuration parameters are able to fine tune the behavior of the # defragmentation process. If you are not sure about what they mean it is # a good idea to leave the defaults untouched. # Active defragmentation is disabled by default # activedefrag no # Minimum amount of fragmentation waste to start active defrag # active-defrag-ignore-bytes 100mb # Minimum percentage of fragmentation to start active defrag # active-defrag-threshold-lower 10 # Maximum percentage of fragmentation at which we use maximum effort # active-defrag-threshold-upper 100 # Minimal effort for defrag in CPU percentage, to be used when the lower # threshold is reached # active-defrag-cycle-min 1 # Maximal effort for defrag in CPU percentage, to be used when the upper # threshold is reached # active-defrag-cycle-max 25 # Maximum number of set/hash/zset/list fields that will be processed from # the main dictionary scan # active-defrag-max-scan-fields 1000 # Jemalloc background thread for purging will be enabled by default jemalloc-bg-thread yes # It is possible to pin different threads and processes of Redis to specific # CPUs in your system, in order to maximize the performances of the server. # This is useful both in order to pin different Redis threads in different # CPUs, but also in order to make sure that multiple Redis instances running # in the same host will be pinned to different CPUs. # # Normally you can do this using the "taskset" command, however it is also # possible to this via Redis configuration directly, both in Linux and FreeBSD. # # You can pin the server/IO threads, bio threads, aof rewrite child process, and # the bgsave child process. The syntax to specify the cpu list is the same as # the taskset command: # # Set redis server/io threads to cpu affinity 0,2,4,6: # server-cpulist 0-7:2 # # Set bio threads to cpu affinity 1,3: # bio-cpulist 1,3 # # Set aof rewrite child process to cpu affinity 8,9,10,11: # aof-rewrite-cpulist 8-11 # # Set bgsave child process to cpu affinity 1,10,11 # bgsave-cpulist 1,10-11 # In some cases redis will emit warnings and even refuse to start if it detects # that the system is in bad state, it is possible to suppress these warnings # by setting the following config which takes a space delimited list of warnings # to suppress # # ignore-warnings ARM64-COW-BUG ================================================ FILE: deployments/lvs/README.md ================================================ # LVS Server Standalone Docker Setup This directory contains Docker and Docker Compose configurations for running the LVS (Long Video Summarization) Server as a standalone container. ## Files - `docker-compose.yml` - Docker Compose configuration - `docker-run-lvs-server3.sh` - Standalone docker run script (legacy) - `config.yaml` - Application configuration file (mounted into container) - `.env.lvs-server-standalone` - Environment variables (create this file) ## Quick Start with Docker Compose ### 1. Create Environment File Create a `.env.lvs-server-standalone` file with your configuration: ```bash # Container Configuration CONTAINER_IMAGE=nvcr.io/nvidia/vss-core/vss-long-video-summarization:3.1.0 GPU_DEVICES=2,3 # Port Configuration BACKEND_PORT=38111 LVS_MCP_PORT=38112 FRONTEND_PORT=38113 # Model Cache Directory (optional) MODEL_ROOT_DIR=/path/to/model/cache # Database Configuration - Milvus MILVUS_DB_HOST=localhost MILVUS_DB_GRPC_PORT=19530 # Database Configuration - Elasticsearch ES_HOST=localhost ES_PORT=9200 # Database Backend Selection (vector_db or elasticsearch_db) LVS_DATABASE_BACKEND=vector_db # LLM Configuration LVS_LLM_MODEL_NAME=meta/llama-3.1-70b-instruct LVS_LLM_BASE_URL=http://localhost:9233/v1 NVIDIA_API_KEY=nvapi-xxxxx # Embedding Configuration LVS_EMB_ENABLE=true LVS_EMB_MODEL_NAME=nvidia/nv-embedqa-e5-v5 LVS_EMB_BASE_URL=http://localhost:9232/v1 ``` ### 2. Start the Service ```bash docker compose up -d ``` ### 3. View Logs ```bash docker compose logs -f lvs-server ``` ### 4. Stop the Service ```bash docker compose down ``` ## Configuration Details ### Config File Mounting The `config.yaml` file is automatically mounted into the container at `/app/config.yaml`. The environment variable `CA_RAG_CONFIG_PATH=/app/config.yaml` is set to point to this location. ### GPU Configuration The compose file uses the GPU devices specified in the `GPU_DEVICES` environment variable (default: `2,3`). Ensure you have: - NVIDIA Docker runtime installed - Docker Compose with GPU support ### Port Mappings The following ports are exposed: - `BACKEND_PORT` (default: 38111) - Backend API - `LVS_MCP_PORT` (default: 38112) - LVS MCP service - `FRONTEND_PORT` (default: 38113) - Frontend UI ### Model Cache Directory If `MODEL_ROOT_DIR` is set in your `.env` file, that directory will be mounted into the container for model caching. This speeds up subsequent starts by avoiding re-downloading models. ## Network Configuration By default, the compose file uses bridge networking to enable port mapping. If you need to use host networking instead: 1. Uncomment the `network_mode: host` line in `docker-compose.yml` 2. Comment out the `ports:` section (host mode ignores port mappings) ## Database Backends The LVS server supports two database backends: ### Milvus (vector_db) ```bash LVS_DATABASE_BACKEND=vector_db MILVUS_DB_HOST=localhost MILVUS_DB_GRPC_PORT=19530 ``` ### Elasticsearch ```bash LVS_DATABASE_BACKEND=elasticsearch_db ES_HOST=localhost ES_PORT=9200 ``` ## Troubleshooting ### Container won't start - Check GPU availability: `nvidia-smi` - Verify environment file exists: `ls -la .env.lvs-server-standalone` - Check logs: `docker compose logs lvs-server` ### Port conflicts - Modify port values in `.env.lvs-server-standalone` - Ensure ports are not already in use: `netstat -tuln | grep ` ### Configuration not loading - Verify `config.yaml` exists in the same directory as `docker-compose.yml` - Check that `CA_RAG_CONFIG_PATH` is set correctly in the container: ```bash docker compose exec lvs-server env | grep CA_RAG_CONFIG_PATH ``` ## Alternative: Shell Script You can also use the legacy shell script instead of Docker Compose: ```bash ./docker-run-lvs-server3.sh ``` This script provides the same functionality but uses `docker run` directly. ================================================ FILE: deployments/lvs/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: lvs-server: image: ${CONTAINER_IMAGE:-nvcr.io/nvidia/vss-core/vss-long-video-summarization:3.1.0} container_name: lvs-server profiles: ["bp_developer_lvs_2d"] # GPU configuration deploy: resources: reservations: devices: - driver: nvidia device_ids: ['${GPU_DEVICES:-0}'] capabilities: [gpu] network_mode: host # Volume mounts volumes: # Mount config.yaml and set CA_RAG_CONFIG_PATH to this location - $MDX_SAMPLE_APPS_DIR/lvs/configs/config.yaml:/app/config.yaml:ro # Optional: Mount model cache directory if MODEL_ROOT_DIR is set - ${MODEL_ROOT_DIR:-/tmp/model_cache}:${MODEL_ROOT_DIR:-/tmp/model_cache} # Environment variables from .env file plus config path environment: # Config file path - points to mounted config.yaml - CA_RAG_CONFIG=/app/config.yaml # Database configuration - MILVUS_DB_HOST=${MILVUS_DB_HOST} - MILVUS_DB_GRPC_PORT=${MILVUS_DB_GRPC_PORT} - ES_HOST=${ES_HOST} - ES_PORT=${ES_PORT} - LVS_DATABASE_BACKEND=${LVS_DATABASE_BACKEND:-elasticsearch_db} # LLM configuration - LVS_LLM_MODEL_NAME=${LVS_LLM_MODEL_NAME} - LVS_LLM_BASE_URL=${LLM_BASE_URL:-http://${HOST_IP}:${LLM_PORT}}/v1 - LVS_LLM_API_KEY=${OPENAI_API_KEY:-${NVIDIA_API_KEY}} - VIA_VLM_ENDPOINT=${VLM_BASE_URL:-http://${HOST_IP}:${VLM_PORT}}/v1/ - VIA_VLM_API_KEY=${OPENAI_API_KEY:-${VIA_VLM_API_KEY:-not-used}} - NVIDIA_API_KEY=${NVIDIA_API_KEY} # Embedding configuration - LVS_EMB_ENABLE=${LVS_EMB_ENABLE} - LVS_EMB_MODEL_NAME=${LVS_EMB_MODEL_NAME} - LVS_EMB_BASE_URL=${LVS_EMB_BASE_URL} # Port configuration - BACKEND_PORT=${BACKEND_PORT:-38111} - LVS_MCP_PORT=${LVS_MCP_PORT:-38112} - FRONTEND_PORT=${FRONTEND_PORT:-38113} # Model cache directory - MODEL_ROOT_DIR=${MODEL_ROOT_DIR:-/tmp/model_cache} # NGC model cache directory - NGC_MODEL_CACHE=${MODEL_ROOT_DIR:-/tmp/model_cache} # Default Save Events to Elasticsearch - LVS_DISABLE_DB_RESET_ON_REQUEST_DONE=${LVS_DISABLE_DB_RESET_ON_REQUEST_DONE:-true} # VLM configuration - VLM_INPUT_WIDTH=${VLM_INPUT_WIDTH:-1312} - VLM_INPUT_HEIGHT=${VLM_INPUT_HEIGHT:-736} - OPENAI_API_KEY=${OPENAI_API_KEY:-} # Alternative: Load all variables from .env file env_file: - $MDX_SAMPLE_APPS_DIR/lvs/.env healthcheck: test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_PORT:-38111}/v1/ready"] interval: 30s timeout: 10s retries: 10 start_period: 120s restart: always depends_on: nvidia-nemotron-nano-9b-v2: condition: service_healthy required: false nvidia-nemotron-nano-9b-v2-fp8: condition: service_healthy required: false nemotron-3-nano: condition: service_healthy required: false llama-3.3-nemotron-super-49b-v1.5: condition: service_healthy required: false gpt-oss-20b: condition: service_healthy required: false nvidia-nemotron-nano-9b-v2-shared-gpu: condition: service_healthy required: false nvidia-nemotron-nano-9b-v2-fp8-shared-gpu: condition: service_healthy required: false nemotron-3-nano-shared-gpu: condition: service_healthy required: false llama-3.3-nemotron-super-49b-v1.5-shared-gpu: condition: service_healthy required: false gpt-oss-20b-shared-gpu: condition: service_healthy required: false cosmos-reason1-7b: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false cosmos-reason2-8b: condition: service_healthy required: false cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false # Network mode: bridge (default) to enable port mapping # To use host network instead (ignores port mapping), uncomment: # network_mode: host ================================================ FILE: deployments/lvs/configs/config.yaml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. --- # Environment configuration tools: vector_db: type: milvus params: host: !ENV ${MILVUS_DB_HOST} port: !ENV ${MILVUS_DB_GRPC_PORT} tools: embedding: nvidia_embedding elasticsearch_db: type: elasticsearch params: host: !ENV ${ES_HOST} port: !ENV ${ES_PORT} collection_name: lvs-events tools: embedding: nvidia_embedding summarization_llm: type: llm params: model: !ENV ${LVS_LLM_MODEL_NAME} base_url: !ENV ${LVS_LLM_BASE_URL} max_tokens: 10240 temperature: 0.2 top_p: 0.7 api_key: !ENV ${LVS_LLM_API_KEY} nvidia_embedding: type: embedding params: enable: !ENV ${LVS_EMB_ENABLE:false} model: !ENV ${LVS_EMB_MODEL_NAME} base_url: !ENV ${LVS_EMB_BASE_URL} api_key: !ENV ${NVIDIA_API_KEY} functions: summarization: type: vlm_structured_summarization params: time_overlap_threshold: 0.1 time_adjacent_threshold: 5 max_events_per_batch: 50 enable_llm_merging: true tools: db: !ENV ${LVS_DATABASE_BACKEND:elasticsearch_db} llm: summarization_llm summarization_using_llm: type: structured_inference params: prompts: caption: "You are an intelligent traffic system. You must monitor and take note of all traffic related events. Start each event description with a start and end time stamp of the event." # event_extraction_prompt: "Extract structured events from video captions" batch_size: 4 scenario: "traffic monitoring" events: ["accident", "pedestrian crossing", "vehicle crossing", "traffic violation"] batch_response_method: "json_schema" schema: | { "title": "EventExtraction", "description": "Extract structured events from video captions", "type": "object", "properties": { "events": { "type": "array", "items": { "type": "object", "properties": { "start_time": { "type": "number" }, "end_time": { "type": "number" }, "description": { "type": "string" }, "type": { "type": "string" } }, "required": ["start_time", "end_time", "description", "type"] } } }, "required": ["events"] } auto_generate_prompt: true time_metadata_keys: ["start_pts", "end_pts"] tools: llm: summarization_llm db: !ENV ${LVS_DATABASE_BACKEND:elasticsearch_db} context_manager: functions: - summarization ================================================ FILE: deployments/nim/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. include: - path: cosmos-reason1-7b/compose.yml - path: cosmos-reason2-8b/compose.yml - path: gpt-oss-20b/compose.yml - path: llama-3.3-nemotron-super-49b-v1.5/compose.yml - path: nemotron-3-nano/compose.yml - path: nvidia-nemotron-nano-9b-v2/compose.yml - path: nvidia-nemotron-nano-9b-v2-fp8/compose.yml - path: qwen3-vl-8b-instruct/compose.yml ================================================ FILE: deployments/nim/cosmos-reason1-7b/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: cosmos-reason1-7b: image: nvcr.io/nim/nvidia/cosmos-reason1-7b:1.4.1 container_name: cosmos-reason1-7b profiles: - vlm_local_cosmos-reason1-7b runtime: nvidia shm_size: 32gb ports: - ${VLM_PORT:-30082}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" NIM_MODEL_NAME: "${VLM_CUSTOM_WEIGHTS:-}" NIM_DISABLE_LOG_REQUESTS: "1" VLLM_MAX_TOTAL_VIDEO_PIXELS: "150299200" VLM_NIM_KVCACHE_PERCENT: "${VLM_NIM_KVCACHE_PERCENT}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/cosmos-reason1-7b/hw-${HARDWARE_PROFILE}.env - ${VLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - cosmos_reason1_7b_cache:/opt/nim/.cache - type: bind source: ${VLM_CUSTOM_WEIGHTS:-} target: ${VLM_CUSTOM_WEIGHTS:-/nan} bind: create_host_path: false user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${VLM_DEVICE_ID:-0}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $$elapsed"; elapsed=$$((elapsed + 5)); if [ $$elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 cosmos-reason1-7b-shared-gpu: image: nvcr.io/nim/nvidia/cosmos-reason1-7b:1.4.1 container_name: cosmos-reason1-7b-shared-gpu profiles: - vlm_local_shared_cosmos-reason1-7b runtime: nvidia shm_size: 32gb ports: - ${VLM_PORT:-30082}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" NIM_MODEL_NAME: "${VLM_CUSTOM_WEIGHTS:-}" NIM_DISABLE_LOG_REQUESTS: "1" VLLM_MAX_TOTAL_VIDEO_PIXELS: "150299200" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/cosmos-reason1-7b/hw-${HARDWARE_PROFILE}-shared.env - ${VLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - cosmos_reason1_7b_cache:/opt/nim/.cache - type: bind source: ${VLM_CUSTOM_WEIGHTS:-} target: ${VLM_CUSTOM_WEIGHTS:-/nan} bind: create_host_path: false user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${SHARED_LLM_VLM_DEVICE_ID:-${VLM_DEVICE_ID:-0}}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $$elapsed"; elapsed=$$((elapsed + 5)); if [ $$elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 volumes: cosmos_reason1_7b_cache: ================================================ FILE: deployments/nim/cosmos-reason1-7b/hw-H100-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.8 NIM_MAX_MODEL_LEN=16000 NIM_MAX_NUM_SEQS=16 ================================================ FILE: deployments/nim/cosmos-reason1-7b/hw-H100.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT="${VLM_NIM_KVCACHE_PERCENT}" ================================================ FILE: deployments/nim/cosmos-reason1-7b/hw-L40S.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.8 NIM_MAX_MODEL_LEN=32768 NIM_MAX_NUM_SEQS=4 MAX_JOBS=4 ================================================ FILE: deployments/nim/cosmos-reason1-7b/hw-OTHER-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/cosmos-reason1-7b/hw-OTHER.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/cosmos-reason1-7b/hw-RTXPRO6000BW-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.4 NIM_MAX_MODEL_LEN=16384 NIM_MAX_NUM_SEQS=4 MAX_JOBS=4 ================================================ FILE: deployments/nim/cosmos-reason1-7b/hw-RTXPRO6000BW.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT="${VLM_NIM_KVCACHE_PERCENT}" ================================================ FILE: deployments/nim/cosmos-reason2-8b/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: cosmos-reason2-8b: image: nvcr.io/nim/nvidia/cosmos-reason2-8b:1.6.0 container_name: cosmos-reason2-8b profiles: - vlm_local_cosmos-reason2-8b runtime: nvidia shm_size: 32gb ports: - ${VLM_PORT:-30082}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" NIM_MODEL_NAME: "${VLM_CUSTOM_WEIGHTS:-}" VLM_NIM_KVCACHE_PERCENT: "${VLM_NIM_KVCACHE_PERCENT}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/cosmos-reason2-8b/hw-${HARDWARE_PROFILE}.env - ${VLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - cosmos_reason2_8b_cache:/opt/nim/.cache - type: bind source: ${VLM_CUSTOM_WEIGHTS:-} target: ${VLM_CUSTOM_WEIGHTS:-/nan} bind: create_host_path: false user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${VLM_DEVICE_ID:-0}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $$elapsed"; elapsed=$$((elapsed + 5)); if [ $$elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 cosmos-reason2-8b-shared-gpu: image: nvcr.io/nim/nvidia/cosmos-reason2-8b:1.6.0 container_name: cosmos-reason2-8b-shared-gpu profiles: - vlm_local_shared_cosmos-reason2-8b runtime: nvidia shm_size: 32gb ports: - ${VLM_PORT:-30082}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" NIM_MODEL_NAME: "${VLM_CUSTOM_WEIGHTS:-}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/cosmos-reason2-8b/hw-${HARDWARE_PROFILE}-shared.env - ${VLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - cosmos_reason2_8b_cache:/opt/nim/.cache - type: bind source: ${VLM_CUSTOM_WEIGHTS:-} target: ${VLM_CUSTOM_WEIGHTS:-/nan} bind: create_host_path: false user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${SHARED_LLM_VLM_DEVICE_ID:-${VLM_DEVICE_ID:-0}}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $$elapsed"; elapsed=$$((elapsed + 5)); if [ $$elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 volumes: cosmos_reason2_8b_cache: ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-DGX-SPARK-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.4 NIM_MAX_MODEL_LEN=16384 NIM_MAX_NUM_SEQS=4 NIM_DISABLE_CUDA_GRAPH=1 ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-DGX-SPARK.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.8 NIM_MAX_MODEL_LEN=32768 NIM_MAX_NUM_SEQS=4 NIM_DISABLE_CUDA_GRAPH=1 ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-H100-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.4 NIM_MAX_MODEL_LEN=32768 NIM_MAX_NUM_SEQS=4 MAX_JOBS=4 NIM_DISABLE_MM_PREPROCESSOR_CACHE=1 ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-H100.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT="${VLM_NIM_KVCACHE_PERCENT}" NIM_DISABLE_MM_PREPROCESSOR_CACHE=1 ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-L40S.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.8 NIM_MAX_MODEL_LEN=32768 NIM_MAX_NUM_SEQS=4 MAX_JOBS=4 NIM_DISABLE_MM_PREPROCESSOR_CACHE=1 ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-OTHER-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-OTHER.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-RTXPRO6000BW-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.4 NIM_MAX_MODEL_LEN=32768 NIM_MAX_NUM_SEQS=4 MAX_JOBS=4 NIM_DISABLE_MM_PREPROCESSOR_CACHE=1 ================================================ FILE: deployments/nim/cosmos-reason2-8b/hw-RTXPRO6000BW.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT="${VLM_NIM_KVCACHE_PERCENT}" NIM_DISABLE_MM_PREPROCESSOR_CACHE=1 ================================================ FILE: deployments/nim/fallback-override.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/gpt-oss-20b/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: gpt-oss-20b-init: image: alpine:3.23.2 profiles: - llm_local_gpt-oss-20b - llm_local_shared_gpt-oss-20b user: root volumes: - gpt_oss_20b_cache:/opt/nim/.cache command: sh -c "chmod -R 777 /opt/nim/.cache" restart: "no" gpt-oss-20b: image: nvcr.io/nim/openai/gpt-oss-20b:1 container_name: gpt-oss-20b profiles: - llm_local_gpt-oss-20b runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/gpt-oss-20b/hw-${HARDWARE_PROFILE}.env - ${LLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - gpt_oss_20b_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${LLM_DEVICE_ID:-0}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $elapsed"; elapsed=$((elapsed + 5)); if [ $elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 depends_on: gpt-oss-20b-init: condition: service_completed_successfully required: true gpt-oss-20b-shared-gpu: image: nvcr.io/nim/openai/gpt-oss-20b:1 container_name: gpt-oss-20b-shared-gpu profiles: - llm_local_shared_gpt-oss-20b runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/gpt-oss-20b/hw-${HARDWARE_PROFILE}-shared.env - ${LLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - gpt_oss_20b_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${SHARED_LLM_VLM_DEVICE_ID:-${LLM_DEVICE_ID:-0}}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $elapsed"; elapsed=$((elapsed + 5)); if [ $elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 depends_on: gpt-oss-20b-init: condition: service_completed_successfully required: true cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false rtvi-vlm: condition: service_healthy required: false rtvi-embed: condition: service_healthy required: false volumes: gpt_oss_20b_cache: ================================================ FILE: deployments/nim/gpt-oss-20b/hw-H100-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/gpt-oss-20b/hw-H100.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/gpt-oss-20b/hw-OTHER-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/gpt-oss-20b/hw-OTHER.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/gpt-oss-20b/hw-RTXPRO6000BW-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/gpt-oss-20b/hw-RTXPRO6000BW.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/llama-3.3-nemotron-super-49b-v1.5/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: llama-3.3-nemotron-super-49b-v1.5: image: nvcr.io/nim/nvidia/llama-3.3-nemotron-super-49b-v1.5:1 container_name: llama-3.3-nemotron-super-49b-v1.5 profiles: - llm_local_llama-3.3-nemotron-super-49b-v1.5 runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/llama-3.3-nemotron-super-49b-v1.5/hw-${HARDWARE_PROFILE}.env - ${LLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - llama_3.3_nemotron_super_49b_v1.5_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${LLM_DEVICE_ID:-0}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $elapsed"; elapsed=$((elapsed + 5)); if [ $elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 llama-3.3-nemotron-super-49b-v1.5-shared-gpu: image: nvcr.io/nim/nvidia/llama-3.3-nemotron-super-49b-v1.5:1 container_name: llama-3.3-nemotron-super-49b-v1.5-shared-gpu profiles: - llm_local_shared_llama-3.3-nemotron-super-49b-v1.5 runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/llama-3.3-nemotron-super-49b-v1.5/hw-${HARDWARE_PROFILE}-shared.env - ${LLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - llama_3.3_nemotron_super_49b_v1.5_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${SHARED_LLM_VLM_DEVICE_ID:-${LLM_DEVICE_ID:-0}}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $elapsed"; elapsed=$((elapsed + 5)); if [ $elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 depends_on: cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false rtvi-vlm: condition: service_healthy required: false rtvi-embed: condition: service_healthy required: false volumes: llama_3.3_nemotron_super_49b_v1.5_cache: ================================================ FILE: deployments/nim/llama-3.3-nemotron-super-49b-v1.5/hw-H100-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/llama-3.3-nemotron-super-49b-v1.5/hw-H100.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/llama-3.3-nemotron-super-49b-v1.5/hw-OTHER-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/llama-3.3-nemotron-super-49b-v1.5/hw-OTHER.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/llama-3.3-nemotron-super-49b-v1.5/hw-RTXPRO6000BW-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/llama-3.3-nemotron-super-49b-v1.5/hw-RTXPRO6000BW.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nemotron-3-nano/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: nemotron-3-nano-init: image: alpine:3.23.2 container_name: llm-nim-init profiles: - llm_local_nemotron-3-nano - llm_local_shared_nemotron-3-nano user: root volumes: - nemotron_3_nano_cache:/opt/nim/.cache command: sh -c "chmod -R 777 /opt/nim/.cache" restart: "no" nemotron-3-nano: image: nvcr.io/nim/nvidia/nemotron-3-nano:1 container_name: nemotron-3-nano profiles: - llm_local_nemotron-3-nano runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/nemotron-3-nano/hw-${HARDWARE_PROFILE}.env - ${LLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - nemotron_3_nano_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${LLM_DEVICE_ID:-0}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $elapsed"; elapsed=$((elapsed + 5)); if [ $elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 depends_on: nemotron-3-nano-init: condition: service_completed_successfully required: true nemotron-3-nano-shared-gpu: image: nvcr.io/nim/nvidia/nemotron-3-nano:1 container_name: nemotron-3-nano-shared-gpu profiles: - llm_local_shared_nemotron-3-nano runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/nemotron-3-nano/hw-${HARDWARE_PROFILE}-shared.env - ${LLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - nemotron_3_nano_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${SHARED_LLM_VLM_DEVICE_ID:-${LLM_DEVICE_ID:-0}}" depends_on: nemotron-3-nano-init: condition: service_completed_successfully required: true cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false rtvi-vlm: condition: service_healthy required: false rtvi-embed: condition: service_healthy required: false restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $elapsed"; elapsed=$((elapsed + 5)); if [ $elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 volumes: nemotron_3_nano_cache: ================================================ FILE: deployments/nim/nemotron-3-nano/hw-H100-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.5 NIM_MAX_NUM_SEQS=2 NIM_MAX_MODEL_LEN=32000 MAX_JOBS=4 NIM_MODEL_PROFILE=vllm-fp8-tp1-pp1-27ac2ff073b4ce11dd17e47d0526609589e76091f36cc5fc32b567b18e57ef27 ================================================ FILE: deployments/nim/nemotron-3-nano/hw-H100.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nemotron-3-nano/hw-OTHER-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nemotron-3-nano/hw-OTHER.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nemotron-3-nano/hw-RTXPRO6000BW-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.5 NIM_MAX_NUM_SEQS=2 NIM_MAX_MODEL_LEN=32000 MAX_JOBS=4 NIM_MODEL_PROFILE=vllm-fp8-tp1-pp1-27ac2ff073b4ce11dd17e47d0526609589e76091f36cc5fc32b567b18e57ef27 ================================================ FILE: deployments/nim/nemotron-3-nano/hw-RTXPRO6000BW.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: nvidia-nemotron-nano-9b-v2: image: nvcr.io/nim/nvidia/nvidia-nemotron-nano-9b-v2:1 container_name: nvidia-nemotron-nano-9b-v2 profiles: - llm_local_nvidia-nemotron-nano-9b-v2 runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" PYTORCH_CUDA_ALLOC_CONF: 'expandable_segments:True' env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/nvidia-nemotron-nano-9b-v2/hw-${HARDWARE_PROFILE}.env - ${LLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - nvidia_nemotron_nano_9b_v2_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${LLM_DEVICE_ID:-0}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $elapsed"; elapsed=$((elapsed + 5)); if [ $elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 nvidia-nemotron-nano-9b-v2-shared-gpu: image: nvcr.io/nim/nvidia/nvidia-nemotron-nano-9b-v2:1 container_name: nvidia-nemotron-nano-9b-v2-shared-gpu profiles: - llm_local_shared_nvidia-nemotron-nano-9b-v2 runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" PYTORCH_CUDA_ALLOC_CONF: 'expandable_segments:True' env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/nvidia-nemotron-nano-9b-v2/hw-${HARDWARE_PROFILE}-shared.env - ${LLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - nvidia_nemotron_nano_9b_v2_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${SHARED_LLM_VLM_DEVICE_ID:-${LLM_DEVICE_ID:-0}}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/v1/health/live; else elapsed=0; while ! curl -fs http://localhost:8000/v1/health/ready > /dev/null 2>&1; do sleep 5; echo "elapsed $elapsed"; elapsed=$((elapsed + 5)); if [ $elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/v1/health/live; fi'] interval: 60s timeout: 650s retries: 2 depends_on: cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false rtvi-vlm: condition: service_healthy required: false rtvi-embed: condition: service_healthy required: false volumes: nvidia_nemotron_nano_9b_v2_cache: ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2/hw-H100-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.4 NIM_MAX_NUM_SEQS=4 NIM_MAX_MODEL_LEN=128000 NIM_LOW_MEMORY_MODE=1 ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2/hw-H100.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2/hw-L40S.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.8 NIM_MAX_NUM_SEQS=4 NIM_MAX_MODEL_LEN=128000 NIM_LOW_MEMORY_MODE=1 ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2/hw-OTHER-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2/hw-OTHER.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2/hw-RTXPRO6000BW-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. NIM_KVCACHE_PERCENT=0.4 NIM_MAX_NUM_SEQS=4 NIM_MAX_MODEL_LEN=128000 MAX_JOBS=4 NIM_LOW_MEMORY_MODE=1 ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2/hw-RTXPRO6000BW.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: nvidia-nemotron-nano-9b-v2-fp8-toolcall-init: # Fetch toolcall parser from Hugging Face (public repo, no HF_TOKEN required). image: docker.io/alpine/curl:8.12.1 container_name: nvidia-nemotron-nano-9b-v2-fp8-toolcall-init profiles: - llm_local_nvidia-nemotron-nano-9b-v2-fp8 - llm_local_shared_nvidia-nemotron-nano-9b-v2-fp8 volumes: - nvidia_nemotron_nano_9b_v2_fp8_toolcall_parser_script:/out command: - sh - -c - "curl -fSL -o /out/nemotron_toolcall_parser_no_streaming.py https://huggingface.co/nvidia/NVIDIA-Nemotron-Nano-9B-v2/resolve/main/nemotron_toolcall_parser_no_streaming.py" restart: "no" nvidia-nemotron-nano-9b-v2-fp8: # Nemotron-Nano-V2 and tool-parser (nemotron_toolcall_parser_no_streaming.py) require vLLM 25.12+; 25.10 does not support Nemotron. image: nvcr.io/nvidia/vllm:25.12.post1-py3 command: - python3 - -m - vllm.entrypoints.openai.api_server - --model - nvidia/NVIDIA-Nemotron-Nano-9B-v2-FP8 - --trust-remote-code - --tensor-parallel-size - "1" - --gpu-memory-utilization - "0.85" - --port - "8000" - --mamba_ssm_cache_dtype - float32 - --enable-auto-tool-choice - --tool-parser-plugin # Script path from init-container volume (fetched from Hugging Face, no token). - /opt/toolcall_parser/nemotron_toolcall_parser_no_streaming.py - --tool-call-parser - nemotron_json container_name: nvidia-nemotron-nano-9b-v2-fp8 profiles: - llm_local_nvidia-nemotron-nano-9b-v2-fp8 runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-${HARDWARE_PROFILE}.env volumes: - nvidia_nemotron_nano_9b_v2_fp8_cache:/opt/nim/.cache - nvidia_nemotron_nano_9b_v2_fp8_toolcall_parser_script:/opt/toolcall_parser:ro user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${LLM_DEVICE_ID:-0}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/health; else elapsed=0; while ! curl -fs http://localhost:8000/health > /dev/null 2>&1; do sleep 5; echo "elapsed $$elapsed"; elapsed=$$((elapsed + 5)); if [ $$elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/health; fi'] interval: 60s timeout: 650s retries: 2 depends_on: nvidia-nemotron-nano-9b-v2-fp8-toolcall-init: condition: service_completed_successfully required: true nvidia-nemotron-nano-9b-v2-fp8-shared-gpu: # Nemotron-Nano-V2 and tool-parser require vLLM 25.12+ (see rel-25-12 release notes). image: nvcr.io/nvidia/vllm:25.12.post1-py3 command: - python3 - -m - vllm.entrypoints.openai.api_server - --model - nvidia/NVIDIA-Nemotron-Nano-9B-v2-FP8 - --trust-remote-code - --tensor-parallel-size - "1" - --gpu-memory-utilization - "0.40" - --port - "8000" - --mamba_ssm_cache_dtype - float32 - --enable-auto-tool-choice - --tool-parser-plugin # Script path from init-container volume (fetched from Hugging Face, no token). - /opt/toolcall_parser/nemotron_toolcall_parser_no_streaming.py - --tool-call-parser - nemotron_json container_name: nvidia-nemotron-nano-9b-v2-fp8-shared-gpu profiles: - llm_local_shared_nvidia-nemotron-nano-9b-v2-fp8 runtime: nvidia shm_size: 16GB ports: - ${LLM_PORT:-30081}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-${HARDWARE_PROFILE}-shared.env volumes: - nvidia_nemotron_nano_9b_v2_fp8_cache:/opt/nim/.cache - nvidia_nemotron_nano_9b_v2_fp8_toolcall_parser_script:/opt/toolcall_parser:ro user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${SHARED_LLM_VLM_DEVICE_ID:-${LLM_DEVICE_ID:-0}}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/health; else elapsed=0; while ! curl -fs http://localhost:8000/health > /dev/null 2>&1; do sleep 5; echo "elapsed $$elapsed"; elapsed=$$((elapsed + 5)); if [ $$elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/health; fi'] interval: 60s timeout: 650s retries: 2 depends_on: nvidia-nemotron-nano-9b-v2-fp8-toolcall-init: condition: service_completed_successfully required: true cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false rtvi-vlm: condition: service_healthy required: false rtvi-embed: condition: service_healthy required: false volumes: nvidia_nemotron_nano_9b_v2_fp8_cache: nvidia_nemotron_nano_9b_v2_fp8_toolcall_parser_script: ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-AGX-THOR-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-AGX-THOR.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-DGX-SPARK-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-DGX-SPARK.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-IGX-THOR-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-IGX-THOR.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-OTHER-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/nvidia-nemotron-nano-9b-v2-fp8/hw-OTHER.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/qwen3-vl-8b-instruct/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: qwen3-vl-8b-instruct: image: nvcr.io/nvidia/vllm:25.12.post1-py3 command: python3 -m vllm.entrypoints.openai.api_server --model Qwen/Qwen3-VL-8B-Instruct --trust-remote-code --tensor-parallel-size 1 --gpu-memory-utilization 0.85 --port 8000 container_name: qwen3-vl-8b-instruct profiles: - vlm_local_qwen3-vl-8b-instruct runtime: nvidia shm_size: 32gb ports: - ${VLM_PORT:-30082}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/qwen3-vl-8b-instruct/hw-${HARDWARE_PROFILE}.env - ${VLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - qwen3_vl_8b_instruct_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${VLM_DEVICE_ID:-0}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/health; else elapsed=0; while ! curl -fs http://localhost:8000/health > /dev/null 2>&1; do sleep 5; echo "elapsed $$elapsed"; elapsed=$$((elapsed + 5)); if [ $$elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/health; fi'] interval: 60s timeout: 650s retries: 2 qwen3-vl-8b-instruct-shared-gpu: image: nvcr.io/nvidia/vllm:25.12.post1-py3 command: python3 -m vllm.entrypoints.openai.api_server --model Qwen/Qwen3-VL-8B-Instruct --trust-remote-code --tensor-parallel-size 1 --gpu-memory-utilization 0.4 --port 8000 --max-model-len 32768 container_name: qwen3-vl-8b-instruct-shared-gpu profiles: - vlm_local_shared_qwen3-vl-8b-instruct runtime: nvidia shm_size: 32gb ports: - ${VLM_PORT:-30082}:8000 environment: NGC_API_KEY: "${NGC_CLI_API_KEY}" env_file: - ${MDX_SAMPLE_APPS_DIR}/nim/qwen3-vl-8b-instruct/hw-${HARDWARE_PROFILE}-shared.env - ${VLM_ENV_FILE:-${MDX_SAMPLE_APPS_DIR}/nim/fallback-override.env} volumes: - qwen3_vl_8b_instruct_cache:/opt/nim/.cache user: "${UID:-1000}:${GID:-1000}" deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${SHARED_LLM_VLM_DEVICE_ID:-${VLM_DEVICE_ID:-0}}" restart: always healthcheck: test: ['CMD-SHELL', 'if [ -f /tmp/ready_done ]; then curl --max-time 30 -f http://localhost:8000/health; else elapsed=0; while ! curl -fs http://localhost:8000/health > /dev/null 2>&1; do sleep 5; echo "elapsed $$elapsed"; elapsed=$$((elapsed + 5)); if [ $$elapsed -ge 600 ]; then echo "Service did not become ready within 10 minutes"; exit 1; fi; done; touch /tmp/ready_done && curl --max-time 30 -f http://localhost:8000/health; fi'] interval: 60s timeout: 650s retries: 2 volumes: qwen3_vl_8b_instruct_cache: ================================================ FILE: deployments/nim/qwen3-vl-8b-instruct/hw-H100-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/qwen3-vl-8b-instruct/hw-H100.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/qwen3-vl-8b-instruct/hw-OTHER-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/qwen3-vl-8b-instruct/hw-OTHER.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/qwen3-vl-8b-instruct/hw-RTXPRO6000BW-shared.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/nim/qwen3-vl-8b-instruct/hw-RTXPRO6000BW.env ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/proxy/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: vss-proxy: image: nginx:1.27-alpine profiles: - "bp_developer_base_2d_proxy" - "bp_developer_search_2d_proxy" - "bp_developer_lvs_2d_proxy" - "bp_developer_alerts_2d_cv_proxy" - "bp_developer_alerts_2d_vlm_proxy" container_name: vss-proxy network_mode: host restart: always environment: PROXY_PORT: ${PROXY_PORT:-7777} VST_SUB_FILTER_HOST: ${HOST_IP:-_DISABLED_} VST_SUB_FILTER_EXTERNAL: ${EXTERNAL_IP:-_DISABLED_} volumes: - ${MDX_SAMPLE_APPS_DIR}/proxy/nginx.conf.template:/etc/nginx/nginx.conf.template:ro command: - /bin/sh - -c - | mkdir -p /tmp/nginx envsubst '$$PROXY_PORT $$VST_SUB_FILTER_HOST $$VST_SUB_FILTER_EXTERNAL' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf exec nginx -g 'daemon off;' healthcheck: test: ["CMD-SHELL", "curl -sf http://127.0.0.1:${PROXY_PORT:-7777}/health || exit 1"] interval: 5s timeout: 3s retries: 30 start_period: 5s ================================================ FILE: deployments/proxy/nginx.conf.template ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. worker_processes auto; error_log stderr info; pid /tmp/nginx/nginx.pid; events { worker_connections 1024; } http { client_body_temp_path /tmp/nginx/client-body; proxy_temp_path /tmp/nginx/proxy; fastcgi_temp_path /tmp/nginx/fastcgi; uwsgi_temp_path /tmp/nginx/uwsgi; scgi_temp_path /tmp/nginx/scgi; include /etc/nginx/mime.types; default_type application/octet-stream; log_format detailed '$remote_addr [$time_local] "$request" ' '$status $body_bytes_sent ' 'rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr'; access_log /dev/stdout detailed; # Large uploads (videos) client_max_body_size 0; # WebSocket upgrade map map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen ${PROXY_PORT}; # --- Agent: WebSocket --- location = /websocket { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400s; proxy_send_timeout 86400s; } # --- Agent: REST API --- location /api/v1/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_read_timeout 300s; proxy_buffering off; proxy_request_buffering off; } # --- Agent: Chat streaming --- location /chat/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_read_timeout 300s; proxy_buffering off; } # --- Agent: Static files (reports) --- location /static/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; } # --- Agent: Health --- location = /health { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; } # --- VST: All /vst/ paths --- location /vst/ { proxy_pass http://127.0.0.1:30888; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; proxy_request_buffering off; # Rewrite raw VST URLs in response bodies to relative proxy paths. # VST returns http://HOST_IP:30888/vst/... URLs in JSON — browsers block # these as Mixed Content when the page is served over HTTPS (e.g. Brev). # Rewriting to /vst/ lets the browser resolve against the current origin. # Match includes /vst/ to avoid doubling the prefix — VST paths already # start with /vst/, and the proxy location is also /vst/. sub_filter_types application/json text/plain; sub_filter_once off; proxy_set_header Accept-Encoding ""; sub_filter "http://${VST_SUB_FILTER_HOST}:30888/vst/" "/vst/"; sub_filter "http://${VST_SUB_FILTER_EXTERNAL}:30888/vst/" "/vst/"; } # --- MDX: Incidents API --- location = /incidents { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } # --- MDX: Health --- location = /livez { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; } # --- UI: Default fallback --- location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # Prevent stale UI assets after profile switches proxy_hide_header Cache-Control; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; } } } ================================================ FILE: deployments/rtvi/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. include: - path: rtvi-embed/rtvi-embed-docker-compose.yml - path: rtvi-vlm/rtvi-vlm-docker-compose.yml ================================================ FILE: deployments/rtvi/rtvi-embed/rtvi-embed-docker-compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Docker Compose for RTVI Embed microservice services: rtvi-embed: # for release, change this to the versioned image from the registry image: nvcr.io/nvidia/vss-core/vss-rt-embed:3.1.0 container_name: rtvi-embed user: "1001:1001" profiles: ["bp_developer_search_2d"] deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${RT_EMBED_DEVICE_ID:-0}" ports: - "${RTVI_EMBED_PORT?}:8000" environment: MODEL_PATH: "${MODEL_PATH:-git:https://huggingface.co/nvidia/Cosmos-Embed1-448p}" MODEL_IMPLEMENTATION_PATH: "${MODEL_IMPLEMENTATION_PATH:-/opt/nvidia/rtvi/rtvi/models/custom/samples/cosmos-embed1}" MODEL_REPOSITORY_SCRIPT_PATH: "${MODEL_REPOSITORY_SCRIPT_PATH:-/opt/nvidia/rtvi/rtvi/models/custom/samples/cosmos-embed1/create_triton_model_repo.py}" NGC_API_KEY: "${NGC_API_KEY:-}" NVIDIA_API_KEY: "${NVIDIA_API_KEY:-NOAPIKEYSET}" NVIDIA_VISIBLE_DEVICES: "${RTVI_EMBED_NVIDIA_VISIBLE_DEVICES:-all}" NUM_VLM_PROCS: "${RTVI_EMBED_NUM_VLM_PROCS:-}" VLM_BATCH_SIZE: "${VLM_BATCH_SIZE:-}" NUM_GPUS: "${RTVI_EMBED_NUM_GPUS:-}" INSTALL_PROPRIETARY_CODECS: "${INSTALL_PROPRIETARY_CODECS:-false}" FORCE_SW_AV1_DECODER: "${FORCE_SW_AV1_DECODER:-}" LOG_LEVEL: "${RTVI_EMBED_LOG_LEVEL:-INFO}" RTVI_RTSP_LATENCY: "${RTVI_EMBED_RTSP_LATENCY:-}" RTVI_RTSP_TIMEOUT: "${RTVI_EMBED_RTSP_TIMEOUT:-}" RTVI_RTSP_RECONNECTION_INTERVAL: "${RTVI_EMBED_RTSP_RECONNECTION_INTERVAL:-5}" RTVI_RTSP_RECONNECTION_WINDOW: "${RTVI_EMBED_RTSP_RECONNECTION_WINDOW:-60}" RTVI_RTSP_RECONNECTION_MAX_ATTEMPTS: "${RTVI_EMBED_RTSP_RECONNECTION_MAX_ATTEMPTS:-10}" ENABLE_OTEL_MONITORING: "${RTVI_EMBED_ENABLE_OTEL_MONITORING:-false}" # Set to 'true' to enable OpenTelemetry OTEL_RESOURCE_ATTRIBUTES: "${RTVI_EMBED_OTEL_RESOURCE_ATTRIBUTES:-}" OTEL_TRACES_EXPORTER: "${RTVI_EMBED_OTEL_TRACES_EXPORTER:-otlp}" OTEL_EXPORTER_OTLP_ENDPOINT: "${RTVI_EMBED_OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}" OTEL_METRIC_EXPORT_INTERVAL: "${RTVI_EMBED_OTEL_METRIC_EXPORT_INTERVAL:-60000}" # Metrics export interval in milliseconds KAFKA_ENABLED: "${RTVI_EMBED_KAFKA_ENABLED:-false}" KAFKA_TOPIC: "${RTVI_EMBED_KAFKA_TOPIC:-vision-embed-messages}" ERROR_MESSAGE_TOPIC: "${RTVI_EMBED_ERROR_MESSAGE_TOPIC:-vision-embed-errors}" KAFKA_BOOTSTRAP_SERVERS: ${HOST_IP}:9092 HF_TOKEN: "${HF_TOKEN:-}" ENABLE_REDIS_ERROR_MESSAGES: "${ENABLE_REDIS_ERROR_MESSAGES:-false}" REDIS_HOST: "${REDIS_HOST:-redis}" REDIS_PORT: "${REDIS_PORT:-6379}" REDIS_DB: "${REDIS_DB:-0}" REDIS_PASSWORD: "${REDIS_PASSWORD:-}" ASSET_DOWNLOAD_TOTAL_TIMEOUT: "${ASSET_DOWNLOAD_TOTAL_TIMEOUT:-300}" ASSET_DOWNLOAD_CONNECT_TIMEOUT: "${ASSET_DOWNLOAD_CONNECT_TIMEOUT:-10}" ENABLE_REQUEST_PROFILING: "${ENABLE_REQUEST_PROFILING:-false}" volumes: - "${ASSET_STORAGE_DIR:-/dummy}${ASSET_STORAGE_DIR:+:/tmp/assets}" - "${NGC_MODEL_CACHE:-rtvi-ngc-model-cache}:/opt/nvidia/rtvi/.rtvi/ngc_model_cache" - "${RTVI_EMBED_LOG_DIR:-/dummy}${RTVI_EMBED_LOG_DIR:+:/opt/nvidia/rtvi/log/rtvi/}" - "${RTVI_EMBED_HF_CACHE:-rtvi-hf-cache}:/tmp/huggingface" - ${MDX_DATA_DIR}/data_log/vst/clip_storage:/home/vst/vst_release/streamer_videos - rtvi-triton-model-repo:/tmp/triton_model_repo ulimits: memlock: soft: -1 hard: -1 stack: 67108864 nofile: soft: 65535 hard: 65535 ipc: host stdin_open: true tty: true extra_hosts: host.docker.internal: host-gateway healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/v1/ready"] interval: 30s timeout: 10s retries: 3 start_period: 1200s restart: unless-stopped volumes: rtvi-hf-cache: rtvi-ngc-model-cache: rtvi-triton-model-repo: ================================================ FILE: deployments/rtvi/rtvi-vlm/rtvi-vlm-docker-compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Docker Compose for RTVI VLM microservice services: rtvi-vlm: # for release, change this to the versioned image from the registry image: nvcr.io/nvidia/vss-core/vss-rt-vlm:${RTVI_VLM_IMAGE_TAG:-3.1.0} container_name: rtvi-vlm user: "1001:1001" shm_size: '16gb' runtime: nvidia profiles: - bp_developer_alerts_2d_vlm - bp_developer_alerts_2d_cv_IGX-THOR - bp_developer_base_2d_IGX-THOR - bp_developer_alerts_2d_cv_AGX-THOR - bp_developer_base_2d_AGX-THOR deploy: resources: reservations: devices: - capabilities: - gpu driver: nvidia device_ids: - "${RT_VLM_DEVICE_ID:-0}" ports: - "${RTVI_VLM_PORT?}:8000" environment: OPENAI_API_KEY: "${OPENAI_API_KEY:-NOAPIKEYSET}" OPENAI_API_VERSION: "${OPENAI_API_VERSION:-}" VIA_VLM_OPENAI_MODEL_DEPLOYMENT_NAME: "${RTVI_VLM_OPENAI_MODEL_DEPLOYMENT_NAME:-}" VIA_VLM_ENDPOINT: "${RTVI_VLM_ENDPOINT:-}" VIA_VLM_API_KEY: "${RTVI_VLM_API_KEY:-${NGC_CLI_API_KEY:-}}" VLM_BATCH_SIZE: "${RTVI_VLM_BATCH_SIZE:-}" VLM_INPUT_WIDTH: "${RTVI_VLM_INPUT_WIDTH:-}" VLM_INPUT_HEIGHT: "${RTVI_VLM_INPUT_HEIGHT:-}" VSS_NUM_GPUS_PER_VLM_PROC: "${RTVI_VLM_NUM_GPUS_PER_VLM_PROC:-}" VLLM_GPU_MEMORY_UTILIZATION: "${RTVI_VLLM_GPU_MEMORY_UTILIZATION:-}" VLLM_IGNORE_EOS: "${RTVI_VLLM_IGNORE_EOS:-false}" VLLM_MAX_NUM_SEQS: "${RTVI_VLLM_MAX_NUM_SEQS:-256}" VLLM_MAX_NUM_BATCHED_TOKENS: "${RTVI_VLLM_MAX_NUM_BATCHED_TOKENS:-5120}" VLM_MAX_MODEL_LEN: "${RTVI_VLM_MAX_MODEL_LEN:-32768}" VLLM_NUM_SCHEDULER_STEPS: "${RTVI_VLLM_NUM_SCHEDULER_STEPS:-8}" VLLM_ENABLE_PREFIX_CACHING: "${RTVI_VLLM_ENABLE_PREFIX_CACHING:-true}" VLLM_DISABLE_MM_PREPROCESSOR_CACHE: "${RTVI_VLLM_DISABLE_MM_PREPROCESSOR_CACHE:-false}" GST_ENABLE_CUSTOM_PARSER_MODIFICATIONS: "${RTVI_VLM_GST_ENABLE_CUSTOM_PARSER_MODIFICATIONS:-1}" VLM_MODEL_TO_USE: "${RTVI_VLM_MODEL_TO_USE:-openai-compat}" MODEL_PATH: "${RTVI_VLM_MODEL_PATH:-ngc:nim/nvidia/cosmos-reason2-8b:hf-1208}" MODEL_IMPLEMENTATION_PATH: "${RTVI_VLM_MODEL_IMPLEMENTATION_PATH:-}" NUM_VLM_PROCS: "${RTVI_VLM_NUM_VLM_PROCS:-}" NUM_GPUS: "${RTVI_VLM_NUM_GPUS:-}" VLM_SYSTEM_PROMPT: "${RTVI_VLM_SYSTEM_PROMPT:-}" VLM_DEFAULT_NUM_FRAMES_PER_SECOND_OR_FIXED_FRAMES_CHUNK: "${RTVI_VLM_DEFAULT_NUM_FRAMES_PER_SECOND_OR_FIXED_FRAMES_CHUNK:-}" NVIDIA_VISIBLE_DEVICES: "${RTVI_VLM_NVIDIA_VISIBLE_DEVICES:-all}" NVIDIA_API_KEY: "${NVIDIA_API_KEY:-NOAPIKEYSET}" LOG_LEVEL: "${RTVI_VLM_LOG_LEVEL:-INFO}" # Install proprietary codecs (e.g., AAC, MP3, H.264) for extended audio/video format support # Set to "true" to enable installation of additional multimedia packages at container startup # Handled by: /opt/nvidia/rtvi/start_rtvi_vlm.sh INSTALL_PROPRIETARY_CODECS: "${INSTALL_PROPRIETARY_CODECS:-false}" # Force software AV1 decoder instead of hardware decoder # Set to "true" for platforms where hardware AV1 decoding is not supported # Handled by: video_file_frame_getter.py (GStreamer decoder configuration) FORCE_SW_AV1_DECODER: "${FORCE_SW_AV1_DECODER:-}" RTVI_RTSP_LATENCY: "${RTVI_VLM_RTSP_LATENCY:-}" RTVI_RTSP_TIMEOUT: "${RTVI_VLM_RTSP_TIMEOUT:-}" RTVI_RTSP_RECONNECTION_INTERVAL: "${RTVI_VLM_RTSP_RECONNECTION_INTERVAL:-5}" RTVI_RTSP_RECONNECTION_WINDOW: "${RTVI_VLM_RTSP_RECONNECTION_WINDOW:-60}" RTVI_RTSP_RECONNECTION_MAX_ATTEMPTS: "${RTVI_VLM_RTSP_RECONNECTION_MAX_ATTEMPTS:-10}" ENABLE_OTEL_MONITORING: "${RTVI_VLM_ENABLE_OTEL_MONITORING:-false}" # Set to 'true' to enable OpenTelemetry OTEL_RESOURCE_ATTRIBUTES: "${RTVI_VLM_OTEL_RESOURCE_ATTRIBUTES:-}" OTEL_TRACES_EXPORTER: "${RTVI_VLM_OTEL_TRACES_EXPORTER:-otlp}" OTEL_EXPORTER_OTLP_ENDPOINT: "${RTVI_VLM_OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}" OTEL_METRIC_EXPORT_INTERVAL: "${RTVI_VLM_OTEL_METRIC_EXPORT_INTERVAL:-60000}" # Metrics export interval in milliseconds KAFKA_ENABLED: "${RTVI_VLM_KAFKA_ENABLED:-true}" KAFKA_TOPIC: "${RTVI_VLM_KAFKA_TOPIC:-vision-llm-messages}" KAFKA_INCIDENT_TOPIC: "${RTVI_VLM_KAFKA_INCIDENT_TOPIC:-vision-llm-events-incidents}" ERROR_MESSAGE_TOPIC: "${RTVI_VLM_ERROR_MESSAGE_TOPIC:-vision-llm-errors}" KAFKA_BOOTSTRAP_SERVERS: ${HOST_IP}:9092 NGC_API_KEY: "${RTVI_VLM_API_KEY:-${NGC_CLI_API_KEY:-}}" RTVI_EXTRA_ARGS: "${RTVI_EXTRA_ARGS:-}" HF_TOKEN: "${HF_TOKEN:-}" ENABLE_REDIS_ERROR_MESSAGES: "${ENABLE_REDIS_ERROR_MESSAGES:-false}" REDIS_HOST: "${REDIS_HOST:-redis}" REDIS_PORT: "${REDIS_PORT:-6379}" REDIS_DB: "${REDIS_DB:-0}" REDIS_PASSWORD: "${REDIS_PASSWORD:-}" VSS_SKIP_INPUT_MEDIA_VERIFICATION: "${VSS_SKIP_INPUT_MEDIA_VERIFICATION:-}" RTVI_ADD_TIMESTAMP_TO_VLM_PROMPT: "${RTVI_ADD_TIMESTAMP_TO_VLM_PROMPT:-}" volumes: - "${ASSET_STORAGE_DIR:-/dummy}${ASSET_STORAGE_DIR:+:/tmp/assets}" - "${RTVI_VLM_HF_CACHE:-rtvi-hf-cache}:/tmp/huggingface" - ${MDX_DATA_DIR}/data_log/vst/clip_storage:/home/vst/vst_release/streamer_videos - "${NGC_MODEL_CACHE:-rtvi-ngc-model-cache}:/opt/nvidia/rtvi/.rtvi/ngc_model_cache" - "${RTVI_VLM_LOG_DIR:-/dummy}${RTVI_VLM_LOG_DIR:+:/opt/nvidia/rtvi/log/rtvi/}" ulimits: memlock: soft: -1 hard: -1 stack: 67108864 nofile: soft: 65535 hard: 65535 ipc: host stdin_open: true tty: true extra_hosts: host.docker.internal: host-gateway healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/v1/health/ready"] interval: 30s timeout: 10s retries: 5 start_period: 1200s restart: unless-stopped depends_on: broker-health-check: condition: service_completed_successfully required: false cosmos-reason1-7b: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false cosmos-reason2-8b: condition: service_healthy required: false cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false volumes: rtvi-hf-cache: rtvi-ngc-model-cache: ================================================ FILE: deployments/vlm-as-verifier/README.md ================================================ ## Deploy with NIM From this directory: ```bash docker compose --profile nim up -d # or explicitly docker compose -f compose.yml --profile nim up -d ``` ## Config quick guide (configs/config.yml) - vst_config: Base URLs for VST APIs and storage. - kafka: Broker settings and topics for input/output events. - vss_agent: Optional VSS endpoints (disabled by default here). - vlm: NIM endpoint/model and video processing params (frames/sampling). - event_bridge: Source/sink via Kafka or Redis (hosts, streams, consumer). - prompt: Preference for payload-provided prompts. - alert_agent: Worker count and clip duration bounds. - websocket: Optional realtime broadcasting (disabled by default). - elastic: Enable and target index for persisted results. - logging: Global log level/format. ================================================ FILE: deployments/vlm-as-verifier/compose.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: alert-bridge: image: nvcr.io/nvidia/vss-core/vss-alert-verification:3.1.0 container_name: alert-bridge profiles: ["bp_wh_2d","bp_smc_2d","bp_ps_2d","bp_developer_alerts_2d_cv"] network_mode: host restart: unless-stopped environment: VLM_BASE_URL: ${VLM_BASE_URL:-http://${HOST_IP}:${VLM_PORT}} VLM_NAME: ${VLM_NAME} # URL rewriting configuration EXTERNAL_IP: ${EXTERNAL_IP} INTERNAL_IP: ${HOST_IP} LLM_MODE: ${LLM_MODE} # remote / local / local_shared VLM_MODE: ${VLM_MODE} # remote / local / local_shared volumes: - ${VLM_AS_VERIFIER_CONFIG_FILE:-/path/to/config.yml}:/app/configs/config.yml:ro - ${VLM_AS_VERIFIER_ALERT_TYPE_CONFIG_FILE:-/path/to/alert_type_config.json}:/app/alert_type_config.json:ro - $MDX_SAMPLE_APPS_DIR/vlm-as-verifier/scripts/env-substitute.py:/app/env-substitute.py:ro tmpfs: - /app/runtime:mode=1777,size=10M depends_on: kafka: condition: service_healthy redis: condition: service_started elasticsearch: condition: service_healthy kafka-topic-init-container: condition: service_completed_successfully cosmos-reason1-7b: condition: service_healthy required: false cosmos-reason1-7b-shared-gpu: condition: service_healthy required: false cosmos-reason2-8b: condition: service_healthy required: false cosmos-reason2-8b-shared-gpu: condition: service_healthy required: false qwen3-vl-8b-instruct: condition: service_healthy required: false qwen3-vl-8b-instruct-shared-gpu: condition: service_healthy required: false rtvi-vlm: condition: service_healthy required: false entrypoint: - /usr/local/bin/python - /app/env-substitute.py - --source - /app/configs/config.yml - --output - /app/runtime/config.yml - -- command: ["/usr/local/bin/python", "enhance_alert_with_vlm.py", "--config", "/app/runtime/config.yml"] ================================================ FILE: deployments/vlm-as-verifier/scripts/env-substitute.py ================================================ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Environment variable substitution script for config files. Works with distroless Python images using tmpfs mount. Usage: python env-substitute.py --source --output -- [args...] """ import os import sys import re import argparse def substitute_env_vars(content): """ Replace ${VAR_NAME} with environment variable values. """ def replacer(match): var_name = match.group(1) value = os.environ.get(var_name, '') if not value: print(f"Warning: Environment variable {var_name} is not set or empty", file=sys.stderr) return value # Match ${VAR_NAME} pattern pattern = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}' return re.sub(pattern, replacer, content) def main(): # Split arguments at '--' separator if '--' in sys.argv: separator_idx = sys.argv.index('--') entrypoint_args = sys.argv[1:separator_idx] command_args = sys.argv[separator_idx + 1:] else: print("Error: Missing '--' separator between entrypoint args and command", file=sys.stderr) print("Usage: env-substitute.py --source --output -- [args...]", file=sys.stderr) sys.exit(1) # Parse named arguments for the entrypoint parser = argparse.ArgumentParser( description='Process config file with environment variable substitution' ) parser.add_argument( '--source', required=True, help='Source config file path (with ${VAR} placeholders)' ) parser.add_argument( '--output', required=True, help='Output config file path (with substituted values)' ) try: args = parser.parse_args(entrypoint_args) except SystemExit as e: sys.exit(e.code) if not command_args: print("Error: No command provided after '--'", file=sys.stderr) sys.exit(1) print(f"Substituting environment variables in config...") print(f" Source: {args.source}") print(f" Output: {args.output}") # Read the source config try: with open(args.source, 'r') as f: config_content = f.read() except FileNotFoundError: print(f"Error: Source config file not found: {args.source}", file=sys.stderr) sys.exit(1) except Exception as e: print(f"Error reading source config: {e}", file=sys.stderr) sys.exit(1) # Substitute environment variables processed_content = substitute_env_vars(config_content) # Write processed config try: os.makedirs(os.path.dirname(args.output), exist_ok=True) with open(args.output, 'w') as f: f.write(processed_content) print(f"Processed config written successfully") except Exception as e: print(f"Error writing processed config: {e}", file=sys.stderr) sys.exit(1) # Execute the original command print(f"Executing: {' '.join(command_args)}") os.execvp(command_args[0], command_args) if __name__ == '__main__': main() ================================================ FILE: deployments/vst/developer/vst/configs/adaptor_config.json ================================================ { "vst": [ { "enabled":true, "id":"044bc643-33c5-479a-b988-10d0bbc4e05c", "name":"onvif", "type":"vst", "need_stream_monitoring": true, "need_rtsp_server": true, "need_recording": true, "need_storage_management": true, "control_adaptor_lib_path":"prebuilts/arch/onvif_client.so", "discovery_adaptor_lib_path":"prebuilts/arch/onvif_discovery.so" }, { "enabled":false, "id":"780bd844-5d1c-11ee-8c99-0242ac120002", "name":"remote", "type":"vst", "need_stream_monitoring": true, "need_rtsp_server": true, "need_recording": true, "need_storage_management": true, "control_adaptor_lib_path":"prebuilts/arch/libremotedevice.so" }, { "enabled":false, "id":"8ada39c7-5e93-43c5-ae5e-21451c8a8d1b", "name":"milestone_onvif", "type":"mms", "ip":"", "user":"", "password":"", "port":"580", "need_rtsp_server": true, "need_stream_monitoring": true, "need_recording": true, "need_storage_management": true, "control_adaptor_lib_path": "prebuilts/arch/onvif_client.so", "media_adaptor_lib_path": "prebuilts/arch/vms_media.so" }, { "enabled":false, "id":"640c3667-81e6-460f-b926-b8008a130dba", "name":"streamer", "type":"streamer", "need_rtsp_server": true, "need_stream_monitoring": false, "need_recording": false, "need_storage_management": false, "control_adaptor_lib_path":"prebuilts/arch/liblocalstreams.so", "discovery_adaptor_lib_path":"prebuilts/arch/libsensordata.so" }, { "enabled":false, "id":"ff2d66fa-ce1f-45ce-8e9d-328f319ca17b", "name":"native", "type":"vst", "need_rtsp_server": true, "need_stream_monitoring": false, "need_recording": true, "need_storage_management": true, "control_adaptor_lib_path":"prebuilts/arch/libnativesensors_control.so", "discovery_adaptor_lib_path":"prebuilts/arch/libnativesensors_discovery.so" }, { "enabled":false, "id":"6cdec7d7-0f30-450c-a78a-756c3e132fd3", "name":"vst_rtsp", "type":"vst", "need_stream_monitoring": true, "need_rtsp_server": true, "need_recording": true, "need_storage_management": true, "control_adaptor_lib_path":"prebuilts/arch/rtsp_streams.so" }, { "enabled":false, "id":"b42cf1ba-1ccf-4bac-979e-a4e28bd98044", "name":"test_vms", "type":"vst", "need_stream_monitoring": true, "need_rtsp_server": true, "need_recording": true, "need_storage_management": true, "control_adaptor_lib_path":"prebuilts/arch/test_control.so", "discovery_adaptor_lib_path":"prebuilts/arch/test_discovery.so" } ] } ================================================ FILE: deployments/vst/developer/vst/configs/nginx-mms.conf ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. worker_processes auto; error_log stderr info; pid /tmp/nginx/nginx.pid; events { worker_connections 1024; } http { client_body_temp_path /tmp/nginx/client-body; proxy_temp_path /tmp/nginx/proxy; fastcgi_temp_path /tmp/nginx/fastcgi; uwsgi_temp_path /tmp/nginx/uwsgi; scgi_temp_path /tmp/nginx/scgi; include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; # Concise log format with essential debugging info log_format detailed '$remote_addr [$time_local] "$request" ' '$status $body_bytes_sent ' 'rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr'; access_log /dev/stdout detailed; # Concise proxy debug format log_format proxy_debug '$remote_addr [$time_local] "$request" ' '$status rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr us:$upstream_status'; client_max_body_size 500M; # WebSocket proxy settings map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 30888; location /health { return 200 'ok'; add_header Content-Type text/plain; } # Return 404 for any /vst/api/ path that is not matched by the specific # proxy blocks defined below. This prevents the UI fallback from # serving index.html for unknown API routes. location ^~ /vst/api/ { return 404; } # Redirect /vst → /vst/ for proper relative asset resolution location = /vst { return 301 /vst/; } # Serve hashed JS/CSS/other static assets under /vst/assets/ location /vst/assets/ { alias /vst-ui/assets/; # The alias path takes care of mapping; rely on default 404 handling } # Serve favicons under /vst/favicon/ location /vst/favicon/ { alias /vst-ui/favicon/; } location /vst/ { # Serve the built React UI from the "vst-ui" folder # Adjust the path below if you mount the build somewhere else in the container alias /vst-ui/; # Default document index index.html; # Support client-side routing (hash or path based) try_files $uri $uri/ /vst/index.html; } # Route sensor APIs to localhost:30000 location /vst/api/v1/sensor/ { # Enable detailed logging for this specific endpoint access_log /dev/stdout proxy_debug; rewrite ^/vst/api/v1/sensor/(.*) /api/v1/sensor/$1 break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } # Route timeline requests to sensor service # More specific routes must come BEFORE the general catch-all route location ~ ^/vst/api/v1/record/([^/]+)/timelines { # Matches: /vst/api/v1/record//timelines rewrite ^/vst/api/v1/record/([^/]+)/timelines /api/v1/sensor/$1/timelines break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } location = /vst/api/v1/record/timelines { # Exact match: /vst/api/v1/record/timelines rewrite ^/vst/api/v1/record/timelines /api/v1/sensor/timelines break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } # Route storage timeline requests to sensor service # More specific routes must come BEFORE the general catch-all route location ~ ^/vst/api/v1/storage/([^/]+)/timelines { # Matches: /vst/api/v1/storage//timelines rewrite ^/vst/api/v1/storage/([^/]+)/timelines /api/v1/sensor/$1/timelines break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } location = /vst/api/v1/storage/timelines { # Exact match: /vst/api/v1/storage/timelines rewrite ^/vst/api/v1/storage/timelines /api/v1/sensor/timelines break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } location ~ ^/vst/api/v1/replay/stream/[^/]+/picture$ { rewrite ^/vst/api/v1/replay/(.*) /api/v1/replay/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; proxy_hide_header Cache-Control; proxy_hide_header Expires; proxy_hide_header Pragma; add_header Cache-Control "public, max-age=3600"; } # Route all other v1 APIs to streamprocessing-ms localhost:10000 # This catch-all must come after the sensor and timeline location blocks location /vst/api/v1/ { rewrite ^/vst/api/v1/(.*) /api/v1/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; # WebSocket specific settings proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # Timeout settings proxy_read_timeout 3600s; # 1 hour proxy_send_timeout 3600s; # 1 hour # Buffer settings for large files proxy_buffering off; proxy_request_buffering off; proxy_hide_header Cache-Control; add_header Cache-Control "no-store" always; } # Route storage endpoint to localhost:10000 location /vst/storage/ { rewrite ^/vst/storage/(.*) /storage/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; # Timeout settings for large file downloads proxy_read_timeout 3600s; # 1 hour proxy_send_timeout 3600s; # 1 hour # Buffer settings for large files proxy_buffering off; proxy_request_buffering off; proxy_hide_header Cache-Control; proxy_hide_header Expires; proxy_hide_header Pragma; add_header Cache-Control "public, max-age=3600"; } } } ================================================ FILE: deployments/vst/developer/vst/configs/nginx-mms.conf.template ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. worker_processes auto; error_log stderr info; pid /tmp/nginx/nginx.pid; events { worker_connections 1024; } http { client_body_temp_path /tmp/nginx/client-body; proxy_temp_path /tmp/nginx/proxy; fastcgi_temp_path /tmp/nginx/fastcgi; uwsgi_temp_path /tmp/nginx/uwsgi; scgi_temp_path /tmp/nginx/scgi; include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; # Concise log format with essential debugging info log_format detailed '$remote_addr [$time_local] "$request" ' '$status $body_bytes_sent ' 'rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr'; access_log /dev/stdout detailed; # Concise proxy debug format log_format proxy_debug '$remote_addr [$time_local] "$request" ' '$status rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr us:$upstream_status'; client_max_body_size 500M; # WebSocket proxy settings map $http_upgrade $connection_upgrade { default upgrade; '' close; } # Strip CORS headers from all upstream services (prevent duplicates) # Applied at http level - affects all servers automatically proxy_hide_header 'Access-Control-Allow-Origin'; proxy_hide_header 'Access-Control-Allow-Methods'; proxy_hide_header 'Access-Control-Allow-Headers'; proxy_hide_header 'Access-Control-Expose-Headers'; proxy_hide_header 'Access-Control-Max-Age'; proxy_hide_header 'Access-Control-Allow-Credentials'; # CORS origin whitelist # - EXTERNAL_IP on ports 30888 and 3000: full CORS with pre-flight # - INTERNAL_IP on any port: CORS for server-side calls # - localhost on any port: CORS for local development map $http_origin $cors_origin { default "0"; # Use "0" instead of empty string for clearer logic # External IP - specific ports (30888, 3000) "~^https?://__EXTERNAL_IP__:30888$" $http_origin; "~^https?://__EXTERNAL_IP__:3000$" $http_origin; # Internal IP - any port (for server-side calls) "~^https?://__INTERNAL_IP__:\d+$" $http_origin; # Localhost - any port (for local development) "~^https?://localhost:\d+$" $http_origin; "~^https?://127.0.0.1:\d+$" $http_origin; } # Single map for pre-flight: combines origin + method check (avoids nested ifs) # Only for EXTERNAL_IP on ports 30888, 3000 + OPTIONS method map "$http_origin:$request_method" $is_preflight { default 0; # External IP - specific ports - OPTIONS requests only "~^https?://__EXTERNAL_IP__:30888:OPTIONS$" 1; "~^https?://__EXTERNAL_IP__:3000:OPTIONS$" 1; } server { listen 30888; # Handle preflight OPTIONS requests (safe - only contains return) if ($is_preflight = 1) { return 204; } # CORS headers for all responses (applied via map - origin whitelist) add_header 'Access-Control-Allow-Origin' $cors_origin always; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,Accept,Origin,streamid,nvstreamer-identifier,nvstreamer-file-name,nvstreamer-chunk-number,nvstreamer-total-chunks,nvstreamer-is-last-chunk,nvstreamer-enable-transcode,nvstreamer-transcode-framerate,nvstreamer-transcode-bitrate' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Max-Age' 3600 always; location /health { return 200 'ok'; add_header Content-Type text/plain; } # Return 404 for any /vst/api/ path that is not matched by the specific # proxy blocks defined below. This prevents the UI fallback from # serving index.html for unknown API routes. location ^~ /vst/api/ { return 404; } # Redirect /vst → /vst/ for proper relative asset resolution location = /vst { return 301 /vst/; } # Serve hashed JS/CSS/other static assets under /vst/assets/ location /vst/assets/ { alias /vst-ui/assets/; # The alias path takes care of mapping; rely on default 404 handling } # Serve favicons under /vst/favicon/ location /vst/favicon/ { alias /vst-ui/favicon/; } location /vst/ { # Serve the built React UI from the "vst-ui" folder # Adjust the path below if you mount the build somewhere else in the container alias /vst-ui/; # Default document index index.html; # Support client-side routing (hash or path based) try_files $uri $uri/ /vst/index.html; } # Route sensor APIs to localhost:30000 location /vst/api/v1/sensor/ { # Enable detailed logging for this specific endpoint access_log /dev/stdout proxy_debug; rewrite ^/vst/api/v1/sensor/(.*) /api/v1/sensor/$1 break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } # Route timeline requests to sensor service # More specific routes must come BEFORE the general catch-all route location ~ ^/vst/api/v1/record/([^/]+)/timelines { # Matches: /vst/api/v1/record//timelines rewrite ^/vst/api/v1/record/([^/]+)/timelines /api/v1/sensor/$1/timelines break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } location = /vst/api/v1/record/timelines { # Exact match: /vst/api/v1/record/timelines rewrite ^/vst/api/v1/record/timelines /api/v1/sensor/timelines break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } # Route storage timeline requests to sensor service # More specific routes must come BEFORE the general catch-all route location ~ ^/vst/api/v1/storage/([^/]+)/timelines { # Matches: /vst/api/v1/storage//timelines rewrite ^/vst/api/v1/storage/([^/]+)/timelines /api/v1/sensor/$1/timelines break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } location = /vst/api/v1/storage/timelines { # Exact match: /vst/api/v1/storage/timelines rewrite ^/vst/api/v1/storage/timelines /api/v1/sensor/timelines break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } location ~ ^/vst/api/v1/replay/stream/[^/]+/picture$ { rewrite ^/vst/api/v1/replay/(.*) /api/v1/replay/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; proxy_hide_header Cache-Control; proxy_hide_header Expires; proxy_hide_header Pragma; add_header Cache-Control "public, max-age=3600"; } # Route all other v1 APIs to streamprocessing-ms localhost:10000 # This catch-all must come after the sensor and timeline location blocks location /vst/api/v1/ { rewrite ^/vst/api/v1/(.*) /api/v1/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; # WebSocket specific settings proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # Timeout settings proxy_read_timeout 3600s; # 1 hour proxy_send_timeout 3600s; # 1 hour # Buffer settings for large files proxy_buffering off; proxy_request_buffering off; proxy_hide_header Cache-Control; add_header Cache-Control "no-store" always; } # Route storage endpoint to localhost:10000 location /vst/storage/ { rewrite ^/vst/storage/(.*) /storage/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; # Timeout settings for large file downloads proxy_read_timeout 3600s; # 1 hour proxy_send_timeout 3600s; # 1 hour # Buffer settings for large files proxy_buffering off; proxy_request_buffering off; proxy_hide_header Cache-Control; proxy_hide_header Expires; proxy_hide_header Pragma; add_header Cache-Control "public, max-age=3600"; } } } ================================================ FILE: deployments/vst/developer/vst/configs/nginx-vst.conf ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. worker_processes auto; error_log stderr info; pid /tmp/nginx/nginx.pid; events { worker_connections 1024; } http { client_body_temp_path /tmp/nginx/client-body; proxy_temp_path /tmp/nginx/proxy; fastcgi_temp_path /tmp/nginx/fastcgi; uwsgi_temp_path /tmp/nginx/uwsgi; scgi_temp_path /tmp/nginx/scgi; include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; # Concise log format with essential debugging info log_format detailed '$remote_addr [$time_local] "$request" ' '$status $body_bytes_sent ' 'rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr'; access_log /dev/stdout detailed; # Concise proxy debug format log_format proxy_debug '$remote_addr [$time_local] "$request" ' '$status rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr us:$upstream_status'; client_max_body_size 500M; # WebSocket proxy settings map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 30888; location /health { return 200 'ok'; add_header Content-Type text/plain; } # Return 404 for any /vst/api/ path that is not matched by the specific # proxy blocks defined below. This prevents the UI fallback from # serving index.html for unknown API routes. location ^~ /vst/api/ { return 404; } # Redirect /vst → /vst/ for proper relative asset resolution location = /vst { return 301 /vst/; } # Serve hashed JS/CSS/other static assets under /vst/assets/ location /vst/assets/ { alias /vst-ui/assets/; # The alias path takes care of mapping; rely on default 404 handling } # Serve favicons under /vst/favicon/ location /vst/favicon/ { alias /vst-ui/favicon/; } location /vst/ { # Serve the built React UI from the "vst-ui" folder # Adjust the path below if you mount the build somewhere else in the container alias /vst-ui/; # Default document index index.html; # Support client-side routing (hash or path based) try_files $uri $uri/ /vst/index.html; } # Route sensor APIs to localhost:30000 location /vst/api/v1/sensor/ { access_log /dev/stdout proxy_debug; rewrite ^/vst/api/v1/sensor/(.*) /api/v1/sensor/$1 break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } location ~ ^/vst/api/v1/replay/stream/[^/]+/picture$ { rewrite ^/vst/api/v1/replay/(.*) /api/v1/replay/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; proxy_hide_header Cache-Control; proxy_hide_header Expires; proxy_hide_header Pragma; add_header Cache-Control "public, max-age=3600"; } # Route all other v1 APIs to streamprocessing-ms localhost:10000 # This catch-all must come after the sensor location block location /vst/api/v1/ { rewrite ^/vst/api/v1/(.*) /api/v1/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; # WebSocket specific settings proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # Timeout settings proxy_read_timeout 3600s; # 1 hour proxy_send_timeout 3600s; # 1 hour # Buffer settings for large files proxy_buffering off; proxy_request_buffering off; proxy_hide_header Cache-Control; add_header Cache-Control "no-store" always; } # Route storage endpoint to localhost:10000 location /vst/storage/ { rewrite ^/vst/storage/(.*) /storage/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; # Timeout settings for large file downloads proxy_read_timeout 3600s; # 1 hour proxy_send_timeout 3600s; # 1 hour # Buffer settings for large files proxy_buffering off; proxy_request_buffering off; proxy_hide_header Cache-Control; proxy_hide_header Expires; proxy_hide_header Pragma; add_header Cache-Control "public, max-age=3600"; } } } ================================================ FILE: deployments/vst/developer/vst/configs/nginx-vst.conf.template ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. worker_processes auto; error_log stderr info; pid /tmp/nginx/nginx.pid; events { worker_connections 1024; } http { client_body_temp_path /tmp/nginx/client-body; proxy_temp_path /tmp/nginx/proxy; fastcgi_temp_path /tmp/nginx/fastcgi; uwsgi_temp_path /tmp/nginx/uwsgi; scgi_temp_path /tmp/nginx/scgi; include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; # Concise log format with essential debugging info log_format detailed '$remote_addr [$time_local] "$request" ' '$status $body_bytes_sent ' 'rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr'; access_log /dev/stdout detailed; # Concise proxy debug format log_format proxy_debug '$remote_addr [$time_local] "$request" ' '$status rt:$request_time ut:$upstream_response_time ' 'up:$upstream_addr us:$upstream_status'; client_max_body_size 500M; # WebSocket proxy settings map $http_upgrade $connection_upgrade { default upgrade; '' close; } # Strip CORS headers from all upstream services (prevent duplicates) # Applied at http level - affects all servers automatically proxy_hide_header 'Access-Control-Allow-Origin'; proxy_hide_header 'Access-Control-Allow-Methods'; proxy_hide_header 'Access-Control-Allow-Headers'; proxy_hide_header 'Access-Control-Expose-Headers'; proxy_hide_header 'Access-Control-Max-Age'; proxy_hide_header 'Access-Control-Allow-Credentials'; # CORS origin whitelist # - EXTERNAL_IP on ports 30888 and 3000: full CORS with pre-flight # - INTERNAL_IP on any port: CORS for server-side calls # - localhost on any port: CORS for local development map $http_origin $cors_origin { default "0"; # Use "0" instead of empty string for clearer logic # External IP - specific ports (30888, 3000) "~^https?://__EXTERNAL_IP__:30888$" $http_origin; "~^https?://__EXTERNAL_IP__:3000$" $http_origin; # Internal IP - any port (for server-side calls) "~^https?://__INTERNAL_IP__:\d+$" $http_origin; # Localhost - any port (for local development) "~^https?://localhost:\d+$" $http_origin; "~^https?://127.0.0.1:\d+$" $http_origin; } # Single map for pre-flight: combines origin + method check (avoids nested ifs) # Only for EXTERNAL_IP on ports 30888, 3000 + OPTIONS method map "$http_origin:$request_method" $is_preflight { default 0; # External IP - specific ports - OPTIONS requests only "~^https?://__EXTERNAL_IP__:30888:OPTIONS$" 1; "~^https?://__EXTERNAL_IP__:3000:OPTIONS$" 1; } server { listen 30888; # Handle preflight OPTIONS requests (safe - only contains return) if ($is_preflight = 1) { return 204; } # CORS headers for all responses (applied via map - origin whitelist) add_header 'Access-Control-Allow-Origin' $cors_origin always; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,Accept,Origin,streamid,nvstreamer-identifier,nvstreamer-file-name,nvstreamer-chunk-number,nvstreamer-total-chunks,nvstreamer-is-last-chunk,nvstreamer-enable-transcode,nvstreamer-transcode-framerate,nvstreamer-transcode-bitrate' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Max-Age' 3600 always; location /health { return 200 'ok'; add_header Content-Type text/plain; } # Return 404 for any /vst/api/ path that is not matched by the specific # proxy blocks defined below. This prevents the UI fallback from # serving index.html for unknown API routes. location ^~ /vst/api/ { return 404; } # Redirect /vst → /vst/ for proper relative asset resolution location = /vst { return 301 /vst/; } # Serve hashed JS/CSS/other static assets under /vst/assets/ location /vst/assets/ { alias /vst-ui/assets/; # The alias path takes care of mapping; rely on default 404 handling } # Serve favicons under /vst/favicon/ location /vst/favicon/ { alias /vst-ui/favicon/; } location /vst/ { # Serve the built React UI from the "vst-ui" folder # Adjust the path below if you mount the build somewhere else in the container alias /vst-ui/; # Default document index index.html; # Support client-side routing (hash or path based) try_files $uri $uri/ /vst/index.html; } # Route sensor APIs to localhost:30000 location /vst/api/v1/sensor/ { access_log /dev/stdout proxy_debug; rewrite ^/vst/api/v1/sensor/(.*) /api/v1/sensor/$1 break; proxy_pass http://localhost:30000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; } location ~ ^/vst/api/v1/replay/stream/[^/]+/picture$ { rewrite ^/vst/api/v1/replay/(.*) /api/v1/replay/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; proxy_hide_header Cache-Control; proxy_hide_header Expires; proxy_hide_header Pragma; add_header Cache-Control "public, max-age=3600"; } # Route all other v1 APIs to streamprocessing-ms localhost:10000 # This catch-all must come after the sensor location block location /vst/api/v1/ { rewrite ^/vst/api/v1/(.*) /api/v1/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; # WebSocket specific settings proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # Timeout settings proxy_read_timeout 3600s; # 1 hour proxy_send_timeout 3600s; # 1 hour # Buffer settings for large files proxy_buffering off; proxy_request_buffering off; proxy_hide_header Cache-Control; add_header Cache-Control "no-store" always; } # Route storage endpoint to localhost:10000 location /vst/storage/ { rewrite ^/vst/storage/(.*) /storage/$1 break; proxy_pass http://localhost:10000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; # Timeout settings for large file downloads proxy_read_timeout 3600s; # 1 hour proxy_send_timeout 3600s; # 1 hour # Buffer settings for large files proxy_buffering off; proxy_request_buffering off; proxy_hide_header Cache-Control; proxy_hide_header Expires; proxy_hide_header Pragma; add_header Cache-Control "public, max-age=3600"; } } } ================================================ FILE: deployments/vst/developer/vst/configs/postgresql.conf ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Connection Settings listen_addresses = '' max_connections = 500 # Logging and Monitoring # log_destination = 'stderr' # logging_collector = on # log_directory = '/tmp/postgresql' # log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log_rotation_age = 1d # log_rotation_size = 10MB # log_min_duration_statement = -1 # log_statement = 'none' # log_checkpoints = on # log_connections = on # log_disconnections = on # log_lock_waits = on # log_temp_files = 0 # log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h ' # Statement Statistics # shared_preload_libraries = 'pg_stat_statements,auto_explain' # pg_stat_statements.track = all # pg_stat_statements.max = 10000 # Auto Explain # auto_explain.log_min_duration = 1000 # auto_explain.log_analyze = on # auto_explain.log_buffers = on # auto_explain.log_timing = on # auto_explain.log_verbose = on # auto_explain.log_nested_statements = on ================================================ FILE: deployments/vst/developer/vst/configs/rtsp_streams.json ================================================ { "Nvstreamer": [ { "enabled": false, "endpoint": "localhost:31000", "api": "/api/v1/sensor/streams", "max_stream_count": 4 } ] } ================================================ FILE: deployments/vst/developer/vst/configs/vst_config.json ================================================ { "network": { "http_port":"30000", "server_domain_name":"", "stunurl_list": ["stun.l.google.com:19302","stun1.l.google.com:19302"], "static_turnurl_list": [], "use_coturn_auth_secret": false, "coturn_turnurl_list_with_secret": [], "use_twilio_stun_turn": false, "twilio_account_sid": "", "twilio_auth_token": "", "use_reverse_proxy": false, "reverse_proxy_server_address": "REVERSE_PROXY_SERVER_ADDRESS:100", "ntp_servers": [], "use_sensor_ntp_time": false, "max_webrtc_out_connections": 40, "max_webrtc_in_connections": 1, "webservice_access_control_list":"", "rtsp_server_port": 30554, "rtsp_server_instances_count": 10, "rtsp_server_use_socket_poll": true, "rtsp_preferred_network_iface":"", "rtcp_rtp_port_multiplex": true, "rtsp_in_base_udp_port_num": -1, "rtsp_out_base_udp_port_num": -1, "rtsp_streaming_over_tcp": false, "rtsp_server_reclamation_client_timeout_sec": 10, "rx_socket_buffer_size":1000000, "tx_socket_buffer_size":1000000, "tx_rtp_packet_size": 1250, "enable_packet_pacing": false, "rtp_packet_pace_time_us": 1000, "rtp_packet_batch_size": 5, "stream_monitor_interval_secs": 5, "udp_latency_ms": 200, "udp_drop_on_latency": false, "webrtc_latency_ms": 1000, "enable_frame_drop": true, "webrtc_video_quality_tunning": { "resolution_2160": { "bitrate_start" : 20000, "bitrate_range" : [10000,50000], "qp_range_I" : [0,20], "qp_range_P" : [0,20] }, "resolution_1440": { "bitrate_start" : 10000, "bitrate_range" : [5000,20000], "qp_range_I" : [0,15], "qp_range_P" : [0,10] }, "resolution_1080": { "bitrate_start" : 5000, "bitrate_range" : [2000,10000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] }, "resolution_720": { "bitrate_start" : 2000, "bitrate_range" : [1000,8000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] }, "resolution_480": { "bitrate_start" : 1000, "bitrate_range" : [500,3000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] } }, "webrtc_peer_conn_timeout_sec": 10, "enable_grpc": false, "grpc_server_port": "50051", "webrtc_in_audio_sender_max_bitrate": 128000, "webrtc_in_video_degradation_preference": "resolution", "webrtc_in_video_sender_max_framerate": 30, "remote_vst_address": "", "webrtc_port_range": {"min":31100, "max":31200}, "enable_websocket_pingpong": false, "websocket_keep_alive_ms": 5000, "ai_bridge_endpoint": "" }, "onvif": { "device_discovery_timeout_secs":10, "onvif_request_timeout_secs":10, "device_discovery_freq_secs":15, "device_discovery_interfaces": [], "max_devices_supported": 210, "default_bitrate_kbps": 8000, "default_framerate": 30, "default_resolution": "1920x1080", "default_gov_length": 60, "onvif_sensor_time_sync_interval_secs": 60, "onvif_sensor_time_sync_compensation_ms": 20 }, "data": { "storage_config_file": "/home/vst/vst_release/configs/vst_storage.json", "storage_threshold_percentage": 95, "storage_monitoring_frequency_secs": 2, "enable_cloud_storage": false, "cloud_storage_type": "minio", "cloud_storage_endpoint": "http://127.0.0.1:9000", "cloud_storage_access_key": "", "cloud_storage_secret_key": "", "cloud_storage_bucket": "videos", "cloud_storage_region": "", "cloud_storage_use_ssl": false, "nv_streamer_directory_path": "/home/vst/vst_release/streamer_videos/", "nv_streamer_loop_playback":false, "nv_streamer_seekable":false, "nv_streamer_max_upload_file_size_MB": 10000, "nv_streamer_media_container_supported": ["mp4","mkv"], "nv_streamer_metadata_container_supported": ["json"], "nv_streamer_rtsp_server_output_buffer_size_kb": 1000, "supported_video_codecs": ["h264", "h265"], "supported_audio_codecs": ["pcmu","pcma","mpeg4-generic"], "enable_aging_policy": false, "max_video_download_size_MB":1000, "always_recording": true, "event_recording": false, "event_record_length_secs": 10, "record_buffer_length_secs": 2, "use_software_path": false, "use_webrtc_inbuilt_encoder": "", "webrtc_in_fixed_resolution": "1280x720", "webrtc_in_max_framerate": 30, "webrtc_in_video_bitrate_thresold_percentage": 50, "webrtc_in_passthrough": false, "webrtc_sender_quality": "pass_through", "enable_rtsp_server_sei_metadata": false, "enable_proxy_server_sei_metadata": true, "gpu_indices" : [], "webrtc_out_enable_insert_sps_pps" : true, "webrtc_out_default_resolution": "1920x1080", "webrtc_out_set_iframe_interval" : 30, "webrtc_out_set_idr_interval" : 256, "webrtc_out_min_drc_interval" : 5, "webrtc_out_encode_fallback_option" : "software", "device_name" : "VST", "device_location" : "", "enable_dec_low_latency_mode": true, "recorder_enable_frame_drop": true, "recorder_max_frame_queue_size_bytes": 16000000, "webrtc_out_enc_quality_tuning": "ultra_low_latency", "webrtc_out_enc_preset": "ultra_fast", "enable_drc": true, "use_centralize_local_db": true, "max_network_db_connections": 10, "default_file_expiry_minutes": 10080, "download_files_timeout_secs": 120 }, "notifications": { "enable_notification": true, "enable_notification_consumer": true, "use_message_broker_consumer" : "kafka", "message_broker_topic_consumer": "mdx-raw", "use_message_broker" : "redis", "message_broker_payload_key": "sensor.id", "message_broker_topic": "vst.event", "message_broker_metadata_topic": "mdx-raw", "redis_server_env_var": "localhost:6379", "kafka_server_address": "localhost:9092", "mqtt_broker_address": "tcp://172.17.0.1:1883" }, "debug": { "enable_perf_logging":true, "enable_qos_monitoring":true, "qos_logfile_path":"./webroot/log/", "qos_data_capture_interval_sec":1, "qos_data_publish_interval_sec":5, "enable_gst_debug_probes":true, "enable_prometheus":false, "prometheus_port": "8080", "enable_highlighting_logs":true, "enable_debug_apis": true, "dump_webrtc_input_stats": false, "enable_frameid_in_webrtc_stream": false, "enable_network_bandwidth_notification" : false, "enable_latency_logging": true, "enable_loopback_multicast": false }, "overlay": { "video_metadata_server": "localhost:9200/mdx-raw*", "video_metadata_query_batch_size_num_frames": 300, "use_video_metadata_protobuf": true, "enable_gem_drawing": true, "analytic_server_address": "", "calibration_file_path": "/home/vst/vst_release/configs/calibration.json", "3d_overlay_sensor_name": "", "calibration_mode": "synthetic", "use_camera_groups": true, "enable_recentering": false, "overlay_text_font_type": "DejaVuSansMono.ttf", "floor_map_file_path": "/home/vst/vst_release/configs/Top.png", "bbox_tolerance_ms": 0, "enable_overlay_skip_frame": false, "overlay_color_code": [ {"Person":[118,185,0,255]}, {"Agility_Digit_Humanoid":[0,113,197,255]}, {"Fourier_GR1_T2_Humanoid":[0,113,197,255]}, {"NovaCarter":[250,194,0,255]}, {"Transporter":[0,133,100,255]}, {"Forklift":[0,113,197,255]}, {"Box":[250,194,0,255]}, {"Pallet":[93,22,130,255]}, {"Crate":[94,94,94,255]}, {"proximity_bubble":[0,255,0,75]}, {"proximity_bubble_inner":[255,0,0,75]}, {"proximity_bubble_outer":[204,85,0,75]}, {"proximity_bubble_border":[255,255,255,255]}, {"proximity_line":[255,255,255,255]} ], "halo_safety_udp_port":-1, "halo_safety_proximity_class": "Forklift", "halo_safety_active_text": "Standard Mode", "halo_safety_active_text_color": "black", "halo_safety_active_text_bg_color": "white", "halo_safety_inactive_text": "Efficient Mode", "halo_safety_inactive_text_color": "green", "halo_safety_inactive_text_bg_color": "white", "halo_safety_text_size": 16 }, "security": { "use_https": false, "use_rtsp_authentication": false, "use_http_digest_authentication": false, "use_multi_user": false, "enable_user_cleanup": false, "session_max_age_sec": 2592000, "multi_user_extra_options": ["Secure", "SameSite=none"], "nv_org_id": "", "nv_ngc_key": "" }, "observability": { "enable_telemetry": false, "otlp_endpoint": "http://localhost:4318/v1/traces" } } ================================================ FILE: deployments/vst/developer/vst/configs/vst_config_kafka.json ================================================ { "network": { "http_port":"30000", "server_domain_name":"", "stunurl_list": ["stun.l.google.com:19302","stun1.l.google.com:19302"], "static_turnurl_list": [], "use_coturn_auth_secret": false, "coturn_turnurl_list_with_secret": [], "use_twilio_stun_turn": false, "twilio_account_sid": "", "twilio_auth_token": "", "use_reverse_proxy": false, "reverse_proxy_server_address": "REVERSE_PROXY_SERVER_ADDRESS:100", "ntp_servers": [], "use_sensor_ntp_time": false, "max_webrtc_out_connections": 40, "max_webrtc_in_connections": 1, "webservice_access_control_list":"", "rtsp_server_port": 30554, "rtsp_server_instances_count": 10, "rtsp_server_use_socket_poll": true, "rtsp_preferred_network_iface":"", "rtcp_rtp_port_multiplex": true, "rtsp_in_base_udp_port_num": -1, "rtsp_out_base_udp_port_num": -1, "rtsp_streaming_over_tcp": false, "rtsp_server_reclamation_client_timeout_sec": 10, "rx_socket_buffer_size":1000000, "tx_socket_buffer_size":1000000, "tx_rtp_packet_size": 1250, "enable_packet_pacing": false, "rtp_packet_pace_time_us": 1000, "rtp_packet_batch_size": 5, "stream_monitor_interval_secs": 5, "udp_latency_ms": 200, "udp_drop_on_latency": false, "webrtc_latency_ms": 1000, "enable_frame_drop": true, "webrtc_video_quality_tunning": { "resolution_2160": { "bitrate_start" : 20000, "bitrate_range" : [10000,50000], "qp_range_I" : [0,20], "qp_range_P" : [0,20] }, "resolution_1440": { "bitrate_start" : 10000, "bitrate_range" : [5000,20000], "qp_range_I" : [0,15], "qp_range_P" : [0,10] }, "resolution_1080": { "bitrate_start" : 5000, "bitrate_range" : [2000,10000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] }, "resolution_720": { "bitrate_start" : 2000, "bitrate_range" : [1000,8000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] }, "resolution_480": { "bitrate_start" : 1000, "bitrate_range" : [500,3000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] } }, "webrtc_peer_conn_timeout_sec": 10, "enable_grpc": false, "grpc_server_port": "50051", "webrtc_in_audio_sender_max_bitrate": 128000, "webrtc_in_video_degradation_preference": "resolution", "webrtc_in_video_sender_max_framerate": 30, "remote_vst_address": "", "webrtc_port_range": {"min":31100, "max":31200}, "enable_websocket_pingpong": false, "websocket_keep_alive_ms": 5000, "ai_bridge_endpoint": "" }, "onvif": { "device_discovery_timeout_secs":10, "onvif_request_timeout_secs":10, "device_discovery_freq_secs":15, "device_discovery_interfaces": [], "max_devices_supported": 210, "default_bitrate_kbps": 8000, "default_framerate": 30, "default_resolution": "1920x1080", "default_gov_length": 60, "onvif_sensor_time_sync_interval_secs": 60, "onvif_sensor_time_sync_compensation_ms": 20 }, "data": { "storage_config_file": "/home/vst/vst_release/configs/vst_storage.json", "storage_threshold_percentage": 95, "storage_monitoring_frequency_secs": 2, "enable_cloud_storage": false, "cloud_storage_type": "minio", "cloud_storage_endpoint": "http://127.0.0.1:9000", "cloud_storage_access_key": "", "cloud_storage_secret_key": "", "cloud_storage_bucket": "videos", "cloud_storage_region": "", "cloud_storage_use_ssl": false, "nv_streamer_directory_path": "/home/vst/vst_release/streamer_videos/", "nv_streamer_loop_playback":false, "nv_streamer_seekable":false, "nv_streamer_max_upload_file_size_MB": 10000, "nv_streamer_media_container_supported": ["mp4","mkv"], "nv_streamer_metadata_container_supported": ["json"], "nv_streamer_rtsp_server_output_buffer_size_kb": 1000, "supported_video_codecs": ["h264", "h265"], "supported_audio_codecs": ["pcmu","pcma","mpeg4-generic"], "enable_aging_policy": false, "max_video_download_size_MB":1000, "always_recording": true, "event_recording": false, "event_record_length_secs": 10, "record_buffer_length_secs": 2, "use_software_path": false, "use_webrtc_inbuilt_encoder": "", "webrtc_in_fixed_resolution": "1280x720", "webrtc_in_max_framerate": 30, "webrtc_in_video_bitrate_thresold_percentage": 50, "webrtc_in_passthrough": false, "webrtc_sender_quality": "pass_through", "enable_rtsp_server_sei_metadata": false, "enable_proxy_server_sei_metadata": true, "gpu_indices" : [], "webrtc_out_enable_insert_sps_pps" : true, "webrtc_out_default_resolution": "1920x1080", "webrtc_out_set_iframe_interval" : 30, "webrtc_out_set_idr_interval" : 256, "webrtc_out_min_drc_interval" : 5, "webrtc_out_encode_fallback_option" : "software", "device_name" : "VST", "device_location" : "", "enable_dec_low_latency_mode": true, "recorder_enable_frame_drop": true, "recorder_max_frame_queue_size_bytes": 16000000, "webrtc_out_enc_quality_tuning": "ultra_low_latency", "webrtc_out_enc_preset": "ultra_fast", "enable_drc": true, "use_centralize_local_db": true, "max_network_db_connections": 10, "default_file_expiry_minutes": 10080, "download_files_timeout_secs": 120 }, "notifications": { "enable_notification": true, "enable_notification_consumer": true, "use_message_broker_consumer" : "kafka", "message_broker_topic_consumer": "mdx-raw", "use_message_broker" : "redis", "message_broker_payload_key": "sensor.id", "message_broker_topic": "vst.event", "message_broker_metadata_topic": "mdx-raw", "redis_server_env_var": "localhost:6379", "kafka_server_address": "localhost:9092", "mqtt_broker_address": "tcp://172.17.0.1:1883" }, "debug": { "enable_perf_logging":true, "enable_qos_monitoring":true, "qos_logfile_path":"./webroot/log/", "qos_data_capture_interval_sec":1, "qos_data_publish_interval_sec":5, "enable_gst_debug_probes":true, "enable_prometheus":false, "prometheus_port": "8080", "enable_highlighting_logs":true, "enable_debug_apis": true, "dump_webrtc_input_stats": false, "enable_frameid_in_webrtc_stream": false, "enable_network_bandwidth_notification" : false, "enable_latency_logging": true, "enable_loopback_multicast": false }, "overlay": { "video_metadata_server": "localhost:9200/mdx-raw*", "video_metadata_query_batch_size_num_frames": 300, "use_video_metadata_protobuf": true, "enable_gem_drawing": true, "analytic_server_address": "", "calibration_file_path": "/home/vst/vst_release/configs/calibration.json", "3d_overlay_sensor_name": "", "calibration_mode": "synthetic", "use_camera_groups": true, "enable_recentering": false, "overlay_text_font_type": "DejaVuSansMono.ttf", "floor_map_file_path": "/home/vst/vst_release/configs/Top.png", "bbox_tolerance_ms": 0, "enable_overlay_skip_frame": false, "overlay_color_code": [ {"Person":[118,185,0,255]}, {"Agility_Digit_Humanoid":[0,113,197,255]}, {"Fourier_GR1_T2_Humanoid":[0,113,197,255]}, {"NovaCarter":[250,194,0,255]}, {"Transporter":[0,133,100,255]}, {"Forklift":[0,113,197,255]}, {"Box":[250,194,0,255]}, {"Pallet":[93,22,130,255]}, {"Crate":[94,94,94,255]}, {"proximity_bubble":[0,255,0,75]}, {"proximity_bubble_inner":[255,0,0,75]}, {"proximity_bubble_outer":[204,85,0,75]}, {"proximity_bubble_border":[255,255,255,255]}, {"proximity_line":[255,255,255,255]} ], "halo_safety_udp_port":-1, "halo_safety_proximity_class": "Forklift", "halo_safety_active_text": "Standard Mode", "halo_safety_active_text_color": "black", "halo_safety_active_text_bg_color": "white", "halo_safety_inactive_text": "Efficient Mode", "halo_safety_inactive_text_color": "green", "halo_safety_inactive_text_bg_color": "white", "halo_safety_text_size": 16 }, "security": { "use_https": false, "use_rtsp_authentication": false, "use_http_digest_authentication": false, "use_multi_user": false, "enable_user_cleanup": false, "session_max_age_sec": 2592000, "multi_user_extra_options": ["Secure", "SameSite=none"], "nv_org_id": "", "nv_ngc_key": "" }, "observability": { "enable_telemetry": false, "otlp_endpoint": "http://localhost:4318/v1/traces" } } ================================================ FILE: deployments/vst/developer/vst/configs/vst_config_redis.json ================================================ { "network": { "http_port":"30000", "server_domain_name":"", "stunurl_list": ["stun.l.google.com:19302","stun1.l.google.com:19302"], "static_turnurl_list": [], "use_coturn_auth_secret": false, "coturn_turnurl_list_with_secret": [], "use_twilio_stun_turn": false, "twilio_account_sid": "", "twilio_auth_token": "", "use_reverse_proxy": false, "reverse_proxy_server_address": "REVERSE_PROXY_SERVER_ADDRESS:100", "ntp_servers": [], "use_sensor_ntp_time": false, "max_webrtc_out_connections": 40, "max_webrtc_in_connections": 1, "webservice_access_control_list":"", "rtsp_server_port": 30554, "rtsp_server_instances_count": 10, "rtsp_server_use_socket_poll": true, "rtsp_preferred_network_iface":"", "rtcp_rtp_port_multiplex": true, "rtsp_in_base_udp_port_num": -1, "rtsp_out_base_udp_port_num": -1, "rtsp_streaming_over_tcp": false, "rtsp_server_reclamation_client_timeout_sec": 10, "rx_socket_buffer_size":1000000, "tx_socket_buffer_size":1000000, "tx_rtp_packet_size": 1250, "enable_packet_pacing": false, "rtp_packet_pace_time_us": 1000, "rtp_packet_batch_size": 5, "stream_monitor_interval_secs": 5, "udp_latency_ms": 200, "udp_drop_on_latency": false, "webrtc_latency_ms": 1000, "enable_frame_drop": true, "webrtc_video_quality_tunning": { "resolution_2160": { "bitrate_start" : 20000, "bitrate_range" : [10000,50000], "qp_range_I" : [0,20], "qp_range_P" : [0,20] }, "resolution_1440": { "bitrate_start" : 10000, "bitrate_range" : [5000,20000], "qp_range_I" : [0,15], "qp_range_P" : [0,10] }, "resolution_1080": { "bitrate_start" : 5000, "bitrate_range" : [2000,10000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] }, "resolution_720": { "bitrate_start" : 2000, "bitrate_range" : [1000,8000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] }, "resolution_480": { "bitrate_start" : 1000, "bitrate_range" : [500,3000], "qp_range_I" : [10,30], "qp_range_P" : [10,30] } }, "webrtc_peer_conn_timeout_sec": 10, "enable_grpc": false, "grpc_server_port": "50051", "webrtc_in_audio_sender_max_bitrate": 128000, "webrtc_in_video_degradation_preference": "resolution", "webrtc_in_video_sender_max_framerate": 30, "remote_vst_address": "", "webrtc_port_range": {"min":31100, "max":31200}, "enable_websocket_pingpong": false, "websocket_keep_alive_ms": 5000, "ai_bridge_endpoint": "" }, "onvif": { "device_discovery_timeout_secs":10, "onvif_request_timeout_secs":10, "device_discovery_freq_secs":15, "device_discovery_interfaces": [], "max_devices_supported": 210, "default_bitrate_kbps": 8000, "default_framerate": 30, "default_resolution": "1920x1080", "default_gov_length": 60, "onvif_sensor_time_sync_interval_secs": 60, "onvif_sensor_time_sync_compensation_ms": 20 }, "data": { "storage_config_file": "/home/vst/vst_release/configs/vst_storage.json", "storage_threshold_percentage": 95, "storage_monitoring_frequency_secs": 2, "enable_cloud_storage": false, "cloud_storage_type": "minio", "cloud_storage_endpoint": "http://127.0.0.1:9000", "cloud_storage_access_key": "", "cloud_storage_secret_key": "", "cloud_storage_bucket": "videos", "cloud_storage_region": "", "cloud_storage_use_ssl": false, "nv_streamer_directory_path": "/home/vst/vst_release/streamer_videos/", "nv_streamer_loop_playback":false, "nv_streamer_seekable":false, "nv_streamer_max_upload_file_size_MB": 10000, "nv_streamer_media_container_supported": ["mp4","mkv"], "nv_streamer_metadata_container_supported": ["json"], "nv_streamer_rtsp_server_output_buffer_size_kb": 1000, "supported_video_codecs": ["h264", "h265"], "supported_audio_codecs": ["pcmu","pcma","mpeg4-generic"], "enable_aging_policy": false, "max_video_download_size_MB":1000, "always_recording": true, "event_recording": false, "event_record_length_secs": 10, "record_buffer_length_secs": 2, "use_software_path": false, "use_webrtc_inbuilt_encoder": "", "webrtc_in_fixed_resolution": "1280x720", "webrtc_in_max_framerate": 30, "webrtc_in_video_bitrate_thresold_percentage": 50, "webrtc_in_passthrough": false, "webrtc_sender_quality": "pass_through", "enable_rtsp_server_sei_metadata": false, "enable_proxy_server_sei_metadata": true, "gpu_indices" : [], "webrtc_out_enable_insert_sps_pps" : true, "webrtc_out_default_resolution": "1920x1080", "webrtc_out_set_iframe_interval" : 30, "webrtc_out_set_idr_interval" : 256, "webrtc_out_min_drc_interval" : 5, "webrtc_out_encode_fallback_option" : "software", "device_name" : "VST", "device_location" : "", "enable_dec_low_latency_mode": true, "recorder_enable_frame_drop": true, "recorder_max_frame_queue_size_bytes": 16000000, "webrtc_out_enc_quality_tuning": "ultra_low_latency", "webrtc_out_enc_preset": "ultra_fast", "enable_drc": true, "use_centralize_local_db": true, "max_network_db_connections": 10, "default_file_expiry_minutes": 10080, "download_files_timeout_secs": 120 }, "notifications": { "enable_notification": true, "enable_notification_consumer": true, "use_message_broker_consumer" : "redis", "message_broker_topic_consumer": "mdx-raw", "use_message_broker" : "redis", "message_broker_payload_key": "sensor.id", "message_broker_topic": "vst.event", "message_broker_metadata_topic": "mdx-raw", "redis_server_env_var": "localhost:6379", "kafka_server_address": "localhost:9092", "mqtt_broker_address": "tcp://172.17.0.1:1883" }, "debug": { "enable_perf_logging":true, "enable_qos_monitoring":true, "qos_logfile_path":"./webroot/log/", "qos_data_capture_interval_sec":1, "qos_data_publish_interval_sec":5, "enable_gst_debug_probes":true, "enable_prometheus":false, "prometheus_port": "8080", "enable_highlighting_logs":true, "enable_debug_apis": true, "dump_webrtc_input_stats": false, "enable_frameid_in_webrtc_stream": false, "enable_network_bandwidth_notification" : false, "enable_latency_logging": true, "enable_loopback_multicast": false }, "overlay": { "video_metadata_server": "localhost:9200/mdx-raw*", "video_metadata_query_batch_size_num_frames": 300, "use_video_metadata_protobuf": true, "enable_gem_drawing": true, "analytic_server_address": "", "calibration_file_path": "/home/vst/vst_release/configs/calibration.json", "3d_overlay_sensor_name": "", "calibration_mode": "synthetic", "use_camera_groups": true, "enable_recentering": false, "overlay_text_font_type": "DejaVuSansMono.ttf", "floor_map_file_path": "/home/vst/vst_release/configs/Top.png", "bbox_tolerance_ms": 0, "enable_overlay_skip_frame": false, "overlay_color_code": [ {"Person":[118,185,0,255]}, {"Agility_Digit_Humanoid":[0,113,197,255]}, {"Fourier_GR1_T2_Humanoid":[0,113,197,255]}, {"NovaCarter":[250,194,0,255]}, {"Transporter":[0,133,100,255]}, {"Forklift":[0,113,197,255]}, {"Box":[250,194,0,255]}, {"Pallet":[93,22,130,255]}, {"Crate":[94,94,94,255]}, {"proximity_bubble":[0,255,0,75]}, {"proximity_bubble_inner":[255,0,0,75]}, {"proximity_bubble_outer":[204,85,0,75]}, {"proximity_bubble_border":[255,255,255,255]}, {"proximity_line":[255,255,255,255]} ], "halo_safety_udp_port":-1, "halo_safety_proximity_class": "Forklift", "halo_safety_active_text": "Standard Mode", "halo_safety_active_text_color": "black", "halo_safety_active_text_bg_color": "white", "halo_safety_inactive_text": "Efficient Mode", "halo_safety_inactive_text_color": "green", "halo_safety_inactive_text_bg_color": "white", "halo_safety_text_size": 16 }, "security": { "use_https": false, "use_rtsp_authentication": false, "use_http_digest_authentication": false, "use_multi_user": false, "enable_user_cleanup": false, "session_max_age_sec": 2592000, "multi_user_extra_options": ["Secure", "SameSite=none"], "nv_org_id": "", "nv_ngc_key": "" } } ================================================ FILE: deployments/vst/developer/vst/configs/vst_storage.json ================================================ { "data_path": "./vst_data/", "video_path": "./vst_video/", "total_video_storage_size_MB": 100000 } ================================================ FILE: deployments/vst/developer/vst/docker-compose.yaml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. include: - path: $MDX_SAMPLE_APPS_DIR/vst/developer/vst/sdr-streamprocessing/sdr-compose.yaml services: sensor-ms-dev: image: ${VST_SENSOR_IMAGE} profiles: ["bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv","bp_developer_alerts_2d_vlm"] user: "0:0" runtime: nvidia entrypoint: ["/bin/bash", "-c", "exec /home/vst/vst_release/launch_vst"] environment: - ADAPTOR=${VST_ADAPTOR} - NEED_RECORDING=false - NEED_RTSPSERVER=false - NEED_STORAGE=false - NEED_STREAM_MONITORING=true - HTTP_PORT=${SENSOR_HTTP_PORT:-30000} - CENTRALIZE_DB_NAME=${CENTRALIZE_DB_NAME} - CENTRALIZE_DB_USERNAME=${CENTRALIZE_DB_USERNAME} - STREAM_PROCESSOR_MODULE_ENDPOINT=${STREAM_PROCESSOR_MODULE_ENDPOINT} volumes: - ${VST_CONFIG_PATH}:/home/vst/vst_release/configs - ${VST_CONFIG_PATH}/vst_config_${STREAM_TYPE:-redis}.json:/home/vst/vst_release/configs/vst_config.json - ${VST_DATA_PATH}:/home/vst/vst_release/vst_data - ${VST_VIDEO_STORAGE_PATH}:/home/vst/vst_release/vst_video network_mode: host container_name: sensor-ms-dev restart: on-failure # Restart only if the container exits with non-zero status healthcheck: test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/${HTTP_PORT:-30000}'"] interval: 10s timeout: 3s retries: 20 start_period: 5s depends_on: centralizedb-dev: condition: service_healthy sdr-streamprocessing: condition: service_started envoy-streamprocessing: condition: service_started centralizedb-dev: image: ${POSTGRES_IMAGE} profiles: ["bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv","bp_developer_alerts_2d_vlm"] user: "0:0" environment: - POSTGRES_USER=${CENTRALIZE_DB_USERNAME} - POSTGRES_DB=${CENTRALIZE_DB_NAME} - POSTGRES_HOST_AUTH_METHOD=trust volumes: - ${VST_VOLUME}/postgres/db:/var/lib/postgresql/data - ${VST_CONFIG_PATH}/postgresql.conf:/etc/postgresql/postgresql.conf - ${VST_DATA_PATH}:/var/run/postgresql network_mode: host container_name: centralizedb-dev restart: always command: - "postgres" - "-c" - "config_file=/etc/postgresql/postgresql.conf" - "-c" - "unix_socket_directories=/var/run/postgresql" healthcheck: test: ["CMD-SHELL", "pg_isready -U vst -d nvcentralizedb"] interval: 5s timeout: 3s retries: 60 start_period: 2s vst-ingress-dev: image: ${NGINX_IMAGE} profiles: ["bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv","bp_developer_alerts_2d_vlm"] user: "0:0" environment: - HOST_IP=${HOST_IP} - EXTERNAL_IP=${EXTERNAL_IP} - VST_INGRESS_HTTP_PORT=${VST_INGRESS_HTTP_PORT} volumes: - ${VST_CONFIG_PATH}/nginx-${NGINX_MODE:-vst}.conf.template:/etc/nginx/nginx.conf.template:ro - ${VST_CONFIG_PATH}/nginx-${NGINX_MODE:-vst}.conf:/etc/nginx/nginx.conf - ${VST_LOGS}/nginx_logs:/var/log/nginx network_mode: host container_name: vst-ingress-dev restart: always # Nginx should always restart as it's critical for routing command: - /bin/sh - -c - | sed 's/__INTERNAL_IP__/${HOST_IP}/g; s/__EXTERNAL_IP__/${EXTERNAL_IP}/g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf exec nginx -g 'daemon off;' healthcheck: test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/$${VST_INGRESS_HTTP_PORT:-30888}'"] interval: 5s timeout: 3s retries: 30 start_period: 2s depends_on: sensor-ms-dev: condition: service_healthy vst-mcp-dev: image: ${VST_MCP_IMAGE} profiles: ["bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv","bp_developer_alerts_2d_vlm"] user: "0:0" environment: - MCP_GATEWAY_CPP_API_BASE_URL=${MCP_GATEWAY_CPP_API_BASE_URL} - MCP_GATEWAY_CPP_API_TIMEOUT=30 - MCP_GATEWAY_SERVER_NAME=vst-mcp-server - MCP_GATEWAY_SERVER_VERSION=1.0.0 - MCP_GATEWAY_SERVER_HOST=${MCP_GATEWAY_SERVER_HOST} - MCP_GATEWAY_SERVER_PORT=${MCP_GATEWAY_SERVER_PORT} - MCP_GATEWAY_LOG_LEVEL=INFO - MCP_GATEWAY_ENABLE_JSONRPC_LOGGING=true network_mode: host container_name: vst-mcp-dev restart: always depends_on: sensor-ms-dev: condition: service_healthy vst-ingress-dev: condition: service_healthy ================================================ FILE: deployments/vst/developer/vst/sdr-streamprocessing/envoy.yaml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. node: cluster: services id: ucs-svc-proxy dynamic_resources: cds_config: api_config_source: transport_api_version: V3 refresh_delay: 5s api_type: REST cluster_names: [xds_cluster] static_resources: listeners: - name: svc_listener address: socket_address: { address: 0.0.0.0, port_value: 10000 } filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress_http access_log: - name: envoy.access_loggers.file typed_config: "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog path: /dev/stdout log_format: json_format: custom_header: "%REQ(MY_CUSTOM_HEADER)%" cluster_header: "%REQ(cluster_header)%" authority: "%REQ(:AUTHORITY)%" method: "%REQ(:METHOD)%" path: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%" protocol: "%PROTOCOL%" response_code: "%RESPONSE_CODE%" response_flags: "%RESPONSE_FLAGS%" route_name: "%ROUTE_NAME%" upstream_host: "%UPSTREAM_HOST%" upstream_cluster: "%UPSTREAM_CLUSTER%" upstream_time_ms: "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%" total_time_ms: "%DURATION%" user_agent: "%REQ(USER-AGENT)%" x_client_namespace: "%REQ(X-CLIENT-NAMESPACE)%" x_forwarded_for: "%REQ(X-FORWARDED-FOR)%" request_id: "%REQ(X-REQUEST-ID)%" grpc_status: "%GRPC_STATUS%" common_http_protocol_options: idle_timeout: 3600s # 1 hour stream_idle_timeout: 300s # 5 mins, must be disabled for long-lived and streaming requests upgrade_configs: - upgrade_type: websocket request_timeout: 300s # 5 mins, must be disabled for long-lived and streaming requests stream_error_on_invalid_http_message: false rds: route_config_name: streamprocessing-ms_route config_source: resource_api_version: V3 api_config_source: request_timeout: 5s refresh_delay: 10s api_type: REST transport_api_version: V3 cluster_names: [xds_cluster] codec_type: AUTO http_filters: - name: envoy.filters.http.lua typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua default_source_code: inline_string: | local redis = require 'redis' local redisHost = os.getenv("WDM_WL_REDIS_SERVER") local redisPort = os.getenv("WDM_WL_REDIS_PORT") local client = redis.connect(redisHost, redisPort) function envoy_on_request(request_handle) local wlObj = os.getenv("WDM_WL_OBJECT_NAME") local routeHeader = os.getenv("ENVOYROUTEHEADER") local noHeaderTargetContainer = os.getenv("NOHEADERTARGETCONTAINER") local noHeaderTargetport = os.getenv("NOHEADERTARGETPORT") local containerName = nil local containerHost = nil id = request_handle:headers():get(routeHeader) if id ~= nil then request_handle:logInfo("id not nil " ..id) containerName = client:hget(wlObj, id ) if containerName ~= nil then request_handle:logInfo("containerName not nil :" ..containerName) request_handle:logInfo(containerName .. containerName) containerHost = client:hget(wlObj.."-pod", containerName) if containerHost ~= nil then request_handle:logInfo("routing stream id "..id.." to "..containerHost) end end if containerName ~= nil then request_handle:logInfo("containerName:" ..containerName) if request_handle:headers():get("Sec-WebSocket-Key") ~= nil then request_handle:logInfo("Websocket request") request_handle:headers():replace ( "upstream-cluster", containerName .. "-" .. containerName .."-websocket" ) else request_handle:logInfo("Not a websocket request") message_encoding_header = request_handle:headers():get("content-type") if message_encoding_header ~= 'application/grpc' then request_handle:headers():replace ( "upstream-cluster", containerName .. "-" .. containerName) else request_handle:headers():replace ( "upstream-cluster", containerName..'-grpc' ) end end end else request_handle:logInfo("Request has no stream header, routing directly") request_handle:headers():replace ( "upstream-cluster", "headerless_service") end end - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - type: STRICT_DNS connect_timeout: 1s typed_extension_protocol_options: envoy.extensions.upstreams.http.v3.HttpProtocolOptions: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions explicit_http_config: http_protocol_options: {} name: xds_cluster load_assignment: cluster_name: xds_cluster endpoints: - lb_endpoints: - endpoint: address: socket_address: address: "127.0.0.1" port_value: 4003 - type: STRICT_DNS connect_timeout: 0.25s name: headerless_service lb_policy: ROUND_ROBIN typed_extension_protocol_options: envoy.extensions.upstreams.http.v3.HttpProtocolOptions: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions explicit_http_config: http_protocol_options: {} load_assignment: cluster_name: headerless_service endpoints: - lb_endpoints: - endpoint: address: socket_address: address: "127.0.0.1" # This should match container endpoint or service name port_value: "30001" # port ================================================ FILE: deployments/vst/developer/vst/sdr-streamprocessing/sdr-compose.yaml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. services: streamprocessing-ms-dev: image: ${VST_STREAM_PROCESSOR_IMAGE} profiles: ["bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv","bp_developer_alerts_2d_vlm"] container_name: streamprocessing-ms-dev network_mode: host runtime: nvidia user: "0:0" entrypoint: ["/bin/bash", "-c", "if [ \"$$VST_INSTALL_ADDITIONAL_PACKAGES\" = \"true\" ]; then /home/vst/vst_release/tools/user_additional_install.sh; fi && exec /home/vst/vst_release/launch_vst"] environment: - VST_INSTALL_ADDITIONAL_PACKAGES=${VST_INSTALL_ADDITIONAL_PACKAGES} - CONTAINER_NAME=streamprocessing-ms - ADAPTOR=${VST_ADAPTOR} - HTTP_PORT=${STREAM_PROCESSOR_HTTP_PORT:-30001} - RTSP_SERVER_PORT=${RTSP_SERVER_PORT} - CENTRALIZE_DB_NAME=${CENTRALIZE_DB_NAME} - CENTRALIZE_DB_USERNAME=${CENTRALIZE_DB_USERNAME} - SENSOR_MODULE_ENDPOINT=${SENSOR_MODULE_ENDPOINT} - VST_INGRESS_ENDPOINT=${VST_INGRESS_ENDPOINT} volumes: - ${VST_CONFIG_PATH}:/home/vst/vst_release/configs - ${VST_DATA_PATH}:/home/vst/vst_release/vst_data - ${VST_VIDEO_STORAGE_PATH}:/home/vst/vst_release/vst_video - ${CLIP_STORAGE_PATH}:/home/vst/vst_release/streamer_videos - ${VST_TEMP_FILES_PATH}:/home/vst/vst_release/webroot/temp_files restart: unless-stopped deploy: restart_policy: condition: always healthcheck: test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/${HTTP_PORT:-30001}'"] interval: 15s timeout: 3s retries: 30 start_period: 20s depends_on: centralizedb-dev: condition: service_healthy redis: condition: service_started sdr-streamprocessing: image: ${SDR_IMAGE} profiles: ["bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv","bp_developer_alerts_2d_vlm"] user: "0:0" network_mode: "host" logging: driver: "json-file" options: max-size: "8192m" max-file: "3" container_name: sdr-streamprocessing volumes: - ./sdr-config:/wdm-configs - /var/run/docker.sock:/var/run/docker.sock - ${VST_DATA_PATH}/sdr/streamprocessing/log:/log environment: PORT: 4003 WDM_CLUSTER_CONFIG_FILE: /wdm-configs/docker_cluster_config.json CONTAINER_NAME: sdr-streamprocessing WDM_MSG_KEY: ${REDIS_MSG_KEY} WDM_WL_REDIS_SERVER: ${REDIS_HOSTADDR} WDM_WL_REDIS_PORT: ${REDIS_PORT} WDM_WL_REDIS_MSG_FIELD: sensor.id WDM_WL_ADD_URL: /api/v1/proxy/stream/add WDM_WL_DELETE_URL: /api/v1/proxy/stream/ WDM_WL_HEALTH_CHECK_URL: /api/v1/proxy/configuration WDM_WL_CHANGE_ID_ADD: camera_proxy WDM_WL_CHANGE_ID_DEL: camera_remove WDM_PRELOAD_WORKLOAD: ./tests/event_pre-roll.json WDM_CLEAR_DATA_WL: true WDM_KFK_ENABLE: false WDM_MSG_TOPIC: vst_events WDM_KFK_BOOTSTRAP_URL: ${KAFKA_BOOTSTRAP_URL} WDM_DS_SWAP_ID_NAME: false WDM_WL_THRESHOLD: 100 WDM_ADD_REMOVE_RETRY_ATTEMPTS: 50 WDM_CLUSTER_TYPE: docker WDM_POD_WATCH_DOCKER_DELAY: 0.5 WDM_RESTART_DS_ON_ADD_FAIL: false WDM_DISABLE_WERKZEUG_LOGGING: true WDM_WL_OBJECT_NAME: streamprocessing-ms WDM_CONSUMER_GRP_ID: sdr-streamprocessing-cg WDM_CLUSTER_CONTAINER_NAMES: '["streamprocessing-ms"]' VST_STREAMS_ENDPOINT: http://localhost:30000/api/v1/sensor/streams VST_STATUS_ENDPOINT: http://localhost:30000/api/v1/sensor/status OTEL_SDK_DISABLED: true WDM_INITIALIZE_FROM_VST: false ENVOY_REQUEST_TIMEOUT: 300 WDM_TARGET_PORT_MAPPING: "{\"streamprocessing-ms-1\": ${STREAM_PROCESSOR_HTTP_PORT}}" OTEL_SERVICE_NAME: SDR_AGENT WDM_REDIS_CACHE_OBJECT: "streamprocessing-data" WDM_WL_NAME_IGNORE_REGEX: "" restart: unless-stopped deploy: resources: limits: memory: 300M restart_policy: condition: always healthcheck: test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/$${PORT:-4003}'"] interval: 15s timeout: 3s retries: 60 start_period: 5s depends_on: redis: condition: service_started streamprocessing-ms-dev: condition: service_healthy envoy-streamprocessing: image: ${ENVOY_PROXY_IMAGE} profiles: ["bp_developer_base_2d","bp_developer_search_2d","bp_developer_lvs_2d","bp_developer_alerts_2d_cv","bp_developer_alerts_2d_vlm"] user: "0:0" command: /usr/local/bin/envoy -c /etc/envoy/envoy.yaml --concurrency 16 --base-id 1 network_mode: "host" container_name: envoy-streamprocessing volumes: - ./envoy.yaml:/etc/envoy/envoy.yaml restart: unless-stopped environment: WDM_WL_REDIS_SERVER: ${REDIS_HOSTADDR} CONTAINER_NAME: envoy-streamprocessing WDM_WL_REDIS_PORT: ${REDIS_PORT} WDM_KFK_BOOTSTRAP_URL: ${KAFKA_BOOTSTRAP_URL} WDM_WL_OBJECT_NAME: streamprocessing-ms ENVOYROUTEHEADER: "streamid" NOHEADERTARGETCONTAINER: "streamprocessing-ms-1" depends_on: redis: condition: service_started streamprocessing-ms-dev: condition: service_healthy ================================================ FILE: deployments/vst/developer/vst/sdr-streamprocessing/sdr-config/data_wl.yaml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: deployments/vst/developer/vst/sdr-streamprocessing/sdr-config/docker_cluster_config.json ================================================ { "streamprocessing-ms-1": { "provisioning_address": "localhost:30001", "process_type": "docker" } } ================================================ FILE: deployments/vst/scripts/user_additional_install.sh ================================================ #!/usr/bin/env bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -e # Exit on any error # Ensure non-interactive mode for apt operations export DEBIAN_FRONTEND=noninteractive # Random initial sleep (0-5 seconds) to stagger container starts INITIAL_SLEEP=$((RANDOM % 6)) # Generate random timeout to avoid thundering herd problem APT_UPDATE_TIMEOUT=$((200 + RANDOM % 101)) # 200-300 seconds for apt-get update MAX_RETRIES=3 echo "Staggering start with ${INITIAL_SLEEP}s delay..." sleep ${INITIAL_SLEEP} # Function to check if dpkg is in a broken state is_dpkg_broken() { # Check for packages in bad state (Half-installed, Unpacked, Reinst-required) if dpkg -l 2>/dev/null | grep -qE "^[HUR]"; then return 0 # dpkg is broken fi # Check dpkg audit for issues if dpkg --audit 2>&1 | grep -q .; then return 0 # dpkg has issues fi return 1 # dpkg is healthy } # Function to fix dpkg state fix_dpkg() { echo "Fixing dpkg state..." # SAFETY: Check if apt/dpkg is already running before touching locks if pgrep -x apt-get >/dev/null 2>&1 || pgrep -x dpkg >/dev/null 2>&1 || pgrep -x apt >/dev/null 2>&1; then echo "Package manager is currently running, cannot safely fix dpkg state" echo "Waiting for package manager to complete..." return 1 fi # Remove stale lock files (safe now that we checked for running processes) echo "Removing stale lock files..." rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock 2>/dev/null || true # Try to configure any pending packages dpkg --configure -a 2>/dev/null || true # Find and remove packages in bad state BAD_PKGS=$(dpkg -l 2>/dev/null | grep -E "^[HUR]" | awk '{print $2}' || true) if [ -n "$BAD_PKGS" ]; then echo "Removing packages in bad state: $BAD_PKGS" for pkg in $BAD_PKGS; do dpkg --remove --force-remove-reinstreq "$pkg" 2>/dev/null || true done fi # Verify fix worked if is_dpkg_broken; then echo "WARNING: dpkg may still have issues after fix attempt" return 1 fi echo "dpkg state fixed successfully" return 0 } echo "Checking and fixing dpkg state..." if ! fix_dpkg; then echo "WARNING: Could not fix dpkg state (package manager may be running)" echo "Proceeding with caution - DPkg::Lock::Timeout will handle lock contention" fi # APT acquire options for robustness and performance # These handle network-level timeouts gracefully without killing dpkg APT_OPTS="-o Acquire::http::Timeout=30 \ -o Acquire::https::Timeout=30 \ -o Acquire::Retries=5 \ -o DPkg::Lock::Timeout=60 \ -o Acquire::ForceIPv4=true \ -o Acquire::http::Pipeline-Depth=0 \ -o Acquire::http::No-Cache=true \ -o Dpkg::Options::=--force-confdef \ -o Dpkg::Options::=--force-confold" echo "Starting package installation for Ubuntu 24.04..." # Optimize APT sources to only include necessary suites and components (HTTPS) echo "Configuring APT sources for optimal performance..." # Detect architecture - only modify sources for aarch64 ARCH=$(uname -m) if [[ "$ARCH" == *"aarch64"* ]]; then # Skip modification if already configured with HTTPS ports.ubuntu.com if [ -f /etc/apt/sources.list.d/ubuntu.sources ] && grep -q "https://ports.ubuntu.com" /etc/apt/sources.list.d/ubuntu.sources; then echo "APT sources already configured with HTTPS ports.ubuntu.com, skipping modification..." else echo "Detected aarch64, configuring HTTPS for ports.ubuntu.com..." cat >/etc/apt/sources.list.d/ubuntu.sources <<'EOF' Types: deb URIs: https://ports.ubuntu.com/ubuntu-ports/ Suites: noble noble-updates Components: main universe Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg Types: deb URIs: https://ports.ubuntu.com/ubuntu-ports/ Suites: noble-security Components: main universe Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg EOF fi fi # Run apt-get update with timeout and retry logic echo "Running apt-get update (timeout: ${APT_UPDATE_TIMEOUT}s)..." for attempt in $(seq 1 $MAX_RETRIES); do if timeout ${APT_UPDATE_TIMEOUT}s apt-get update ${APT_OPTS}; then echo "apt-get update completed successfully" break else if [ $attempt -lt $MAX_RETRIES ]; then echo "apt-get update attempt $attempt/$MAX_RETRIES failed" # SAFE lock cleanup: only if locks exist AND no process running if [ -f /var/lib/dpkg/lock-frontend ] || [ -f /var/lib/dpkg/lock ]; then if ! pgrep -x apt-get >/dev/null 2>&1 && ! pgrep -x dpkg >/dev/null 2>&1 && ! pgrep -x apt >/dev/null 2>&1; then echo "Found stale lock files, clearing..." rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock 2>/dev/null || true else echo "Lock files present but package manager running in another process" fi fi # Clean corrupted apt lists for fresh retry echo "Cleaning apt lists..." rm -rf /var/lib/apt/lists/* echo "Retrying in $((5 * attempt))s..." sleep $((2 * attempt)) else echo "ERROR: apt-get update failed after $MAX_RETRIES attempts" exit 1 fi fi done # Install gstreamer1.0-libav with retry logic echo "Installing gstreamer1.0-libav..." for attempt in $(seq 1 $MAX_RETRIES); do if apt-get install -y ${APT_OPTS} gstreamer1.0-libav; then echo "gstreamer1.0-libav installed successfully" break else if [ $attempt -lt $MAX_RETRIES ]; then echo "Attempt $attempt/$MAX_RETRIES failed" # Check if dpkg is broken and fix if needed if is_dpkg_broken; then echo "Detected dpkg corruption, attempting fix..." fix_dpkg || echo "WARNING: dpkg fix may not have completed successfully" fi echo "Retrying in $((2 * attempt))s..." sleep $((2 * attempt)) else echo "ERROR: Failed to install gstreamer1.0-libav after $MAX_RETRIES attempts" exit 1 fi fi done # Reinstall GStreamer plugins and multimedia libraries (batch 1) with retry logic echo "Reinstalling GStreamer plugins and core multimedia libraries..." for attempt in $(seq 1 $MAX_RETRIES); do if apt-get install --reinstall -y ${APT_OPTS} \ gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ libvo-aacenc0 libfaad2 libswresample-dev libswresample4 libavutil-dev libavutil58 \ libavcodec-dev libavcodec60 libavformat-dev libavformat60 libavfilter-dev libavfilter9 \ libde265-dev libde265-0 libx265-199 libx264-164 libmpeg2encpp-2.1-0 libmpeg2-4 \ libmpg123-0 libbs2b0 libreadline8 libcdio19 libdca0 libdvdnav4 libmjpegutils-2.1-0 \ liba52-0.7.4 libdvdread8 libsbc1 libzvbi0 libmp3lame0 libsidplay1v5 liblrdf0 libneon27; then echo "GStreamer plugins installed successfully" break else if [ $attempt -lt $MAX_RETRIES ]; then echo "Attempt $attempt/$MAX_RETRIES failed" # Check if dpkg is broken and fix if needed if is_dpkg_broken; then echo "Detected dpkg corruption, attempting fix..." fix_dpkg || echo "WARNING: dpkg fix may not have completed successfully" fi echo "Retrying in $((2 * attempt))s..." sleep $((2 * attempt)) else echo "ERROR: Failed to reinstall GStreamer plugins after $MAX_RETRIES attempts" exit 1 fi fi done # Reinstall additional codec libraries (batch 2) with retry logic echo "Reinstalling additional codec libraries..." for attempt in $(seq 1 $MAX_RETRIES); do if apt-get install --reinstall -y ${APT_OPTS} \ libflac12 libxvidcore4; then echo "Codec libraries installed successfully" break else if [ $attempt -lt $MAX_RETRIES ]; then echo "Attempt $attempt/$MAX_RETRIES failed" # Check if dpkg is broken and fix if needed if is_dpkg_broken; then echo "Detected dpkg corruption, attempting fix..." fix_dpkg || echo "WARNING: dpkg fix may not have completed successfully" fi echo "Retrying in $((2 * attempt))s..." sleep $((2 * attempt)) else echo "ERROR: Failed to reinstall codec libraries after $MAX_RETRIES attempts" exit 1 fi fi done # Reinstall libvpx and h264 libraries (batch 3) with retry logic echo "Reinstalling libvpx and h264 libraries..." for attempt in $(seq 1 $MAX_RETRIES); do if apt-get install --reinstall -y ${APT_OPTS} \ libvpx9 libopenh264-7; then echo "libvpx/h264 libraries installed successfully" break else if [ $attempt -lt $MAX_RETRIES ]; then echo "Attempt $attempt/$MAX_RETRIES failed" # Check if dpkg is broken and fix if needed if is_dpkg_broken; then echo "Detected dpkg corruption, attempting fix..." fix_dpkg || echo "WARNING: dpkg fix may not have completed successfully" fi echo "Retrying in $((2 * attempt))s..." sleep $((2 * attempt)) else echo "ERROR: Failed to reinstall libvpx/h264 libraries after $MAX_RETRIES attempts" exit 1 fi fi done # Clean up GStreamer cache echo "Cleaning up GStreamer cache..." rm -rf ~/.cache/gstreamer-1.0/ echo "Installation completed successfully!" ================================================ FILE: scripts/LICENSE-3rd-party-dev-profile.txt ================================================ Third-Party Software Licenses ================================================================================ This file contains the licenses and attributions for third-party software packages used by the developer profile script (scripts/dev-profile.sh). ================================================================================ 1. Bash (GNU Bash) License: GNU General Public License v3.0 (or later) Repository: https://git.savannah.gnu.org/git/bash.git License URL: https://www.gnu.org/licenses/gpl-3.0.html Description: Script interpreter used to execute dev-profile.sh. Full License Text: See: https://www.gnu.org/licenses/gpl-3.0.html 2. getopt (util-linux) License: GNU General Public License v2.0 (or later) Repository: https://github.com/util-linux/util-linux License URL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html Description: Used for parsing command-line options in dev-profile.sh. Full License Text: See: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html 3. curl License: curl License (MIT-style) Repository: https://github.com/curl/curl License URL: https://curl.se/docs/copyright.html Description: Used to query remote LLM/VLM API endpoints (e.g. /v1/models). Full License Text: COPYRIGHT AND PERMISSION NOTICE Copyright (c) 1996 - 2025, Daniel Stenberg, daniel@haxx.se, and many contributors (see the THANKS file). All rights reserved. Permission to use, copy, modify, and distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Except as contained in this notice, the name of a copyright holder shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Software without prior written authorization of the copyright holder. 4. jq License: MIT License Repository: https://github.com/jqlang/jq License URL: https://github.com/jqlang/jq/blob/master/COPYING Description: Used to parse JSON responses from remote LLM/VLM API endpoints. Full License Text: The MIT License (MIT) Copyright (c) 2012 Stephen Dolan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 5. Docker (Docker Engine) License: Apache License 2.0 Repository: https://github.com/moby/moby License URL: https://www.apache.org/licenses/LICENSE-2.0 Description: Used to run containers and log in to nvcr.io; required for docker compose and container lifecycle managed by dev-profile.sh. Full License Text: Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 6. Docker Compose License: Apache License 2.0 Repository: https://github.com/docker/compose License URL: https://www.apache.org/licenses/LICENSE-2.0 Description: Used to bring up and manage the developer profile stack (docker compose up/down) as invoked by dev-profile.sh. Full License Text: See Apache License 2.0 in section 5 (Docker) above, or: https://www.apache.org/licenses/LICENSE-2.0 7. NGC CLI (NVIDIA GPU Cloud CLI) License: NVIDIA Software License Repository: https://ngc.nvidia.com/setup/installers/cli License URL: https://docs.nvidia.com/ngc/ngc-cli/user-guide/index.html Description: Used to download models from NGC (e.g. TAO models for alerts, vss-rt-cv-models for search) and to authenticate to nvcr.io. Required for 'up' when using local or local_shared LLM/VLM. Full License Text: Use of the NGC CLI is subject to NVIDIA's terms and conditions. See: https://docs.nvidia.com/ngc/ngc-cli/user-guide/index.html and the NVIDIA Software License Agreement applicable to the NGC CLI and NGC catalog assets you access. 8. GNU coreutils License: GNU General Public License v3.0 (or later) Repository: https://github.com/coreutils/coreutils License URL: https://www.gnu.org/licenses/gpl-3.0.html Description: Provides basic file and shell utilities used by dev-profile.sh: basename, chmod, cp, cut, dirname, head, mkdir, mktemp, mv, printf, rm, tr. Full License Text: See: https://www.gnu.org/licenses/gpl-3.0.html 9. GNU grep License: GNU General Public License v3.0 (or later) Repository: https://git.savannah.gnu.org/git/grep.git License URL: https://www.gnu.org/licenses/gpl-3.0.html Description: Used for pattern matching in env files and option parsing (grep). Full License Text: See: https://www.gnu.org/licenses/gpl-3.0.html 10. GNU sed License: GNU General Public License v3.0 (or later) Repository: https://git.savannah.gnu.org/git/sed.git License URL: https://www.gnu.org/licenses/gpl-3.0.html Description: Used for in-place editing of generated.env and DGX-SPARK SBSA variable swapping (sed). Full License Text: See: https://www.gnu.org/licenses/gpl-3.0.html 11. GNU awk (gawk) License: GNU General Public License v3.0 (or later) Repository: https://git.savannah.gnu.org/git/gawk.git License URL: https://www.gnu.org/licenses/gpl-3.0.html Description: Used to extract host IP from ip route output (awk). Full License Text: See: https://www.gnu.org/licenses/gpl-3.0.html 12. GNU findutils (xargs) License: GNU General Public License v3.0 (or later) Repository: https://git.savannah.gnu.org/git/findutils.git License URL: https://www.gnu.org/licenses/gpl-3.0.html Description: Used to remove dangling Docker volumes (xargs). Full License Text: See: https://www.gnu.org/licenses/gpl-3.0.html 13. iproute2 (ip) License: GNU General Public License v2.0 (or later) Repository: https://git.kernel.org/pub/scm/network/iproute2/iproute2.git License URL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html Description: Used to determine host IP via "ip route get" (ip). Full License Text: See: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html 14. sudo License: Sudo License (ISC-style) Repository: https://github.com/sudo-project/sudo License URL: https://www.sudo.ws/about/license/ Description: Used for removal of the data directory on "down" (sudo rm -rf). Full License Text: See: https://www.sudo.ws/about/license/ SPDX identifier: ISC ================================================================================ Full License Texts: Apache License 2.0: See: https://www.apache.org/licenses/LICENSE-2.0 MIT License: See: https://opensource.org/licenses/MIT curl License: See: https://curl.se/docs/copyright.html GNU General Public License v2.0: See: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html GNU General Public License v3.0: See: https://www.gnu.org/licenses/gpl-3.0.html ISC License (Sudo License): See: https://www.sudo.ws/about/license/ See also: https://opensource.org/licenses/ISC ================================================================================ ================================================ FILE: scripts/deploy_vss_launchable.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Deploy VSS 3.1 (Video Search & Summarization)\n", "\n", "This notebook deploys the NVIDIA VSS 3.1 Blueprint on GPU-equipped cloud instances.\n", "\n", "**What it does:**\n", "1. Validates GPU hardware and Docker prerequisites\n", "2. Installs and configures the NGC CLI\n", "3. Configures Docker storage for large image pulls\n", "4. Gets the deployment code (from a local path or GitHub)\n", "5. Detects network configuration (internal + external IPs)\n", "6. Runs `dev-profile.sh` to deploy the selected profile\n", "7. Verifies all services are healthy\n", "\n", "**Supported profiles:** `base`, `search`, `alerts`, `lvs` \n", "**Supported hardware:** H100, L40S, RTX PRO 6000 Blackwell, DGX SPARK\n", "\n", "---\n", "\n", "## Prerequisites\n", "\n", "- Linux instance with 2+ NVIDIA GPUs (H100, L40S, RTX PRO 6000 BW, or DGX SPARK)\n", "- NVIDIA driver 550+ and CUDA 12.x installed\n", "- Docker Engine 24+ with Docker Compose v2\n", "- NGC API key from [ngc.nvidia.com](https://ngc.nvidia.com)\n", "- **500GB+ disk space** for Docker images and models. Most GPU cloud instances have a small root disk (~200-250GB) plus a large ephemeral NVMe. Section 4 will auto-detect this and move Docker/containerd storage to the NVMe." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. Configuration\n", "\n", "Set your NGC API key, deployment profile, and hardware below. These variables are used by all subsequent cells." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ============================================================\n", "# REQUIRED: Set these before running anything else\n", "# ============================================================\n", "\n", "NGC_CLI_API_KEY = \"\" # Your NGC API key — get one at https://ngc.nvidia.com\n", "\n", "PROFILE = \"base\" # Deployment profile: base, search, alerts, lvs\n", "\n", "HARDWARE_PROFILE = \"RTXPRO6000BW\" # Hardware: RTXPRO6000BW, H100, L40S, DGX-SPARK, IGX-THOR, AGX-THOR, OTHER\n", "\n", "# ============================================================\n", "# OPTIONAL: Override defaults if needed\n", "# ============================================================\n", "\n", "# Deployment source — set ONE of these:\n", "# DEPLOY_SOURCE_PATH: Path to a pre-extracted repo on this machine (e.g. copied via scp/rsync).\n", "# Must contain scripts/dev-profile.sh and deployments/.\n", "# If empty, clones from GitHub (requires network access).\n", "DEPLOY_SOURCE_PATH = \"\" # e.g. \"/home/ubuntu/video-search-and-summarization\"\n", "\n", "GIT_BRANCH = \"3.1.0\" # Git branch or tag (only used when cloning from GitHub)\n", "\n", "ALERTS_MODE = \"verification\" # Only used when PROFILE=alerts: verification or real-time\n", "\n", "USE_REMOTE_LLM = False # Set True to use a remote LLM endpoint instead of local\n", "USE_REMOTE_VLM = False # Set True to use a remote VLM endpoint instead of local\n", "\n", "# Network overrides (auto-detected in Section 7 if left empty)\n", "HOST_IP_OVERRIDE = \"\" # Internal IP — leave empty for auto-detect\n", "EXTERNAL_IP_OVERRIDE = \"\" # External IP — leave empty for auto-detect" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "# ---- Validate configuration ----\n", "import os, sys\n", "\n", "assert NGC_CLI_API_KEY, \"NGC_CLI_API_KEY is required. Get one at https://ngc.nvidia.com\"\n", "assert PROFILE in (\"base\", \"search\", \"alerts\", \"lvs\"), f\"Invalid PROFILE: {PROFILE}\"\n", "assert HARDWARE_PROFILE in (\"H100\", \"L40S\", \"RTXPRO6000BW\", \"DGX-SPARK\", \"IGX-THOR\", \"AGX-THOR\", \"OTHER\"), \\\n", " f\"Invalid HARDWARE_PROFILE: {HARDWARE_PROFILE}\"\n", "\n", "if PROFILE == \"alerts\":\n", " assert ALERTS_MODE in (\"verification\", \"real-time\"), f\"Invalid ALERTS_MODE: {ALERTS_MODE}\"\n", "\n", "if HARDWARE_PROFILE in (\"DGX-SPARK\", \"IGX-THOR\", \"AGX-THOR\"):\n", " assert PROFILE in (\"base\", \"alerts\"), \\\n", " f\"{HARDWARE_PROFILE} only supports base and alerts profiles, not {PROFILE}\"\n", "\n", "if DEPLOY_SOURCE_PATH:\n", " assert os.path.isdir(DEPLOY_SOURCE_PATH), f\"DEPLOY_SOURCE_PATH does not exist: {DEPLOY_SOURCE_PATH}\"\n", "\n", "# Export NGC key to environment for shell cells and dev-profile.sh\n", "os.environ[\"NGC_CLI_API_KEY\"] = NGC_CLI_API_KEY\n", "\n", "print(\"Configuration valid.\")\n", "print(f\" Profile: {PROFILE}\")\n", "print(f\" Hardware: {HARDWARE_PROFILE}\")\n", "print(f\" Source: {DEPLOY_SOURCE_PATH or f'GitHub (branch: {GIT_BRANCH})'}\")\n", "print(f\" LLM: {'remote' if USE_REMOTE_LLM else 'local'}\")\n", "print(f\" VLM: {'remote' if USE_REMOTE_VLM else 'local'}\")\n", "if PROFILE == \"alerts\":\n", " print(f\" Alerts: {ALERTS_MODE}\")\n", "print(f\" NGC key: {NGC_CLI_API_KEY[:4]}...{NGC_CLI_API_KEY[-4:]}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2. Prerequisites Check\n", "\n", "Verify that the NVIDIA driver, CUDA, Docker, and Docker Compose are installed and functional." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "%%bash\n", "set -e\n", "\n", "echo \"=== NVIDIA Driver & GPU ===\"\n", "nvidia-smi --query-gpu=index,name,driver_version,memory.total --format=csv,noheader\n", "echo \"\"\n", "\n", "echo \"=== GPU Count ===\"\n", "GPU_COUNT=$(nvidia-smi --query-gpu=index --format=csv,noheader | wc -l)\n", "echo \"Detected $GPU_COUNT GPU(s)\"\n", "echo \"\"\n", "\n", "echo \"=== Docker ===\"\n", "docker --version\n", "docker compose version\n", "echo \"\"\n", "\n", "echo \"=== NVIDIA Container Toolkit ===\"\n", "if docker run --rm --gpus all nvidia/cuda:12.0.0-base-ubuntu22.04 nvidia-smi > /dev/null 2>&1; then\n", " echo \"NVIDIA Container Toolkit: OK\"\n", "else\n", " echo \"WARNING: NVIDIA Container Toolkit may not be installed.\"\n", " echo \"Install: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html\"\n", "fi\n", "echo \"\"\n", "\n", "echo \"=== Disk Space ===\"\n", "df -h / | tail -1 | awk '{print \"Root:\", $4, \"available of\", $2}'\n", "echo \"\"\n", "echo \"Prerequisites check complete.\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3. Install NGC CLI\n", "\n", "The NGC CLI is required to download models during deployment. This cell installs it if not already present, then configures it with your API key." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "import subprocess, os, shutil\n", "\n", "def run(cmd, **kwargs):\n", " \"\"\"Run a shell command, raise on failure with output.\"\"\"\n", " r = subprocess.run(cmd, shell=True, capture_output=True, text=True, **kwargs)\n", " if r.returncode != 0:\n", " raise RuntimeError(f\"Command failed: {cmd}\\n{r.stderr}\\n{r.stdout}\")\n", " return r.stdout.strip()\n", "\n", "# Check if NGC CLI is already installed\n", "ngc_path = shutil.which(\"ngc\")\n", "if ngc_path:\n", " ver = run(\"ngc --version 2>&1 | head -1\")\n", " print(f\"NGC CLI already installed: {ver}\")\n", "else:\n", " import platform\n", " arch = platform.machine()\n", " if arch in (\"aarch64\", \"arm64\"):\n", " filename = \"ngccli_linux_arm64.zip\"\n", " else:\n", " filename = \"ngccli_linux.zip\"\n", "\n", " # Use version-pinned URL (update this if a newer version is needed)\n", " NGC_CLI_VERSION = \"4.13.0\"\n", " url = f\"https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli/versions/{NGC_CLI_VERSION}/files/{filename}\"\n", "\n", " print(f\"Installing NGC CLI {NGC_CLI_VERSION} ...\")\n", " run(f\"cd /tmp && wget -q --content-disposition '{url}' -O ngc_cli.zip\")\n", "\n", " # Verify download is not empty\n", " size = os.path.getsize(\"/tmp/ngc_cli.zip\")\n", " if size < 1000:\n", " raise RuntimeError(f\"NGC CLI download failed — file is only {size} bytes. Check the version URL.\")\n", " print(f\" Downloaded {size / 1024 / 1024:.1f} MB\")\n", "\n", " run(\"cd /tmp && unzip -o ngc_cli.zip\")\n", " # NGC bundles its own Python — copy the entire directory\n", " run(\"sudo cp -r /tmp/ngc-cli/* /usr/local/bin/\")\n", " run(\"rm -rf /tmp/ngc_cli.zip /tmp/ngc-cli\")\n", "\n", " ver = run(\"ngc --version 2>&1 | head -1\")\n", " print(f\" Installed: {ver}\")\n", "\n", "# Configure NGC CLI with API key and org\n", "print(\"Configuring NGC CLI...\")\n", "ngc_dir = os.path.expanduser(\"~/.ngc\")\n", "os.makedirs(ngc_dir, exist_ok=True)\n", "\n", "with open(os.path.join(ngc_dir, \"config\"), \"w\") as f:\n", " f.write(f\"\"\";WARNING - This is a machine generated file. Do not edit manually.\n", ";WARNING - To update local config settings, see 'ngc config set -h'.\n", "\n", "[CURRENT]\n", "apikey = {NGC_CLI_API_KEY}\n", "format_type = ascii\n", "org = nvstaging\n", "\"\"\")\n", "\n", "print(\"NGC CLI configured.\")\n", "print(run(\"ngc config current\"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4. Docker & Containerd Storage\n", "\n", "Docker images and containerd layers for VSS require **~250GB** (NIM models, DeepStream, ELK, etc.). Most GPU cloud instances ship with a small root disk (200-250GB) that **will run out of space** during deployment.\n", "\n", "This cell auto-detects whether your root disk is too small and moves Docker and containerd storage to a larger mount. Docker **volumes** (Elasticsearch indices, uploaded videos, Kafka data) are kept on the root disk so your data persists even if the instance is stopped and the ephemeral NVMe is wiped. Images and layers are re-pulled automatically on next deploy.\n", "\n", "**Common NVMe mount points** (auto-detected):\n", "- AWS DLAMI: `/opt/dlami/nvme`\n", "- Brev/Crusoe: `/ephemeral`\n", "- Custom RAID: `/data`\n", "\n", "To override auto-detection, set `STORAGE_ROOT` below." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "import subprocess, json, os, shutil\n", "\n", "STORAGE_ROOT = \"\" # Override: set to a mount path (e.g. \"/mnt/data\") to skip auto-detection\n", "\n", "MIN_ROOT_FREE_GB = 350 # If root has less than this free, move storage\n", "\n", "# --- Auto-detect large mount ---\n", "\n", "def get_disk_free_gb(path):\n", " \"\"\"Return free space in GB for the filesystem containing path.\"\"\"\n", " st = os.statvfs(path)\n", " return (st.f_bavail * st.f_frsize) / (1024 ** 3)\n", "\n", "def get_disk_total_gb(path):\n", " st = os.statvfs(path)\n", " return (st.f_blocks * st.f_frsize) / (1024 ** 3)\n", "\n", "def find_large_mount():\n", " \"\"\"Look for a large non-root mount suitable for Docker storage.\"\"\"\n", " candidates = [\"/opt/dlami/nvme\", \"/ephemeral\", \"/data\"]\n", " for path in candidates:\n", " if os.path.isdir(path) and os.path.ismount(path):\n", " free = get_disk_free_gb(path)\n", " if free > 200:\n", " return path, free\n", " return None, 0\n", "\n", "def find_mount_unit(mount_path):\n", " \"\"\"Convert a mount path to a systemd mount unit name (e.g. /opt/dlami/nvme -> opt-dlami-nvme.mount).\"\"\"\n", " # Strip leading slash, replace remaining slashes with dashes\n", " unit = mount_path.strip(\"/\").replace(\"/\", \"-\") + \".mount\"\n", " # Verify this unit exists on the system\n", " r = subprocess.run([\"systemctl\", \"cat\", unit], capture_output=True, text=True)\n", " if r.returncode == 0:\n", " return unit\n", " return None\n", "\n", "root_free = get_disk_free_gb(\"/\")\n", "root_total = get_disk_total_gb(\"/\")\n", "\n", "print(f\"Root disk: {root_free:.0f} GB free / {root_total:.0f} GB total\")\n", "\n", "if STORAGE_ROOT:\n", " large_mount = STORAGE_ROOT\n", " mount_free = get_disk_free_gb(STORAGE_ROOT)\n", " print(f\"Using override: {STORAGE_ROOT} ({mount_free:.0f} GB free)\")\n", " need_move = True\n", "else:\n", " large_mount, mount_free = find_large_mount()\n", " need_move = root_free < MIN_ROOT_FREE_GB and large_mount is not None\n", "\n", " if large_mount:\n", " print(f\"Large mount: {large_mount} ({mount_free:.0f} GB free)\")\n", " else:\n", " print(\"No large ephemeral mount detected.\")\n", "\n", " if root_free >= MIN_ROOT_FREE_GB:\n", " print(f\"\\nRoot disk has enough space ({root_free:.0f} GB free). No storage move needed.\")\n", " elif not large_mount:\n", " print(f\"\\nWARNING: Root disk only has {root_free:.0f} GB free and no large mount was found.\")\n", " print(\"Deployment may fail due to disk space. Consider attaching a larger volume.\")\n", "\n", "if need_move:\n", " DOCKER_DATA_ROOT = os.path.join(large_mount, \"docker\")\n", " CONTAINERD_ROOT = os.path.join(large_mount, \"containerd\")\n", " VOLUMES_DIR = \"/var/lib/docker/volumes\" # Keep volumes on persistent root disk\n", "\n", " print(f\"\\nMoving Docker and containerd storage to {large_mount}\")\n", " print(f\" Docker images/layers: {DOCKER_DATA_ROOT}\")\n", " print(f\" Containerd: {CONTAINERD_ROOT}\")\n", " print(f\" Docker volumes: {VOLUMES_DIR} (stays on root for persistence)\")\n", "\n", " # --- Check what needs changing ---\n", " daemon_json = \"/etc/docker/daemon.json\"\n", " config = {}\n", " try:\n", " with open(daemon_json) as f:\n", " config = json.load(f)\n", " except (FileNotFoundError, json.JSONDecodeError):\n", " pass\n", "\n", " need_daemon_json = config.get(\"data-root\") != DOCKER_DATA_ROOT\n", "\n", " subprocess.run([\"sudo\", \"mkdir\", \"-p\", DOCKER_DATA_ROOT], check=True)\n", " subprocess.run([\"sudo\", \"mkdir\", \"-p\", VOLUMES_DIR], check=True)\n", "\n", " volumes_link = os.path.join(DOCKER_DATA_ROOT, \"volumes\")\n", " need_volumes_symlink = not (os.path.islink(volumes_link) and os.readlink(volumes_link) == VOLUMES_DIR)\n", "\n", " containerd_link = \"/var/lib/containerd\"\n", " need_containerd = not (os.path.islink(containerd_link) and os.readlink(containerd_link) == CONTAINERD_ROOT)\n", "\n", " # Even if symlinks are correct, ensure NVMe target dirs actually exist\n", " # (they get wiped when ephemeral NVMe is reset on instance stop/start)\n", " need_target_dirs = not os.path.isdir(DOCKER_DATA_ROOT) or not os.path.isdir(CONTAINERD_ROOT)\n", " if need_target_dirs:\n", " print(f\"\\n NVMe target dir(s) missing — recreating...\")\n", " subprocess.run([\"sudo\", \"mkdir\", \"-p\", DOCKER_DATA_ROOT, CONTAINERD_ROOT], check=True)\n", "\n", " if not need_daemon_json and not need_volumes_symlink and not need_containerd:\n", " print(f\"\\n Docker data-root already set to {DOCKER_DATA_ROOT}\")\n", " print(f\" Volumes symlink already correct: {volumes_link} -> {VOLUMES_DIR}\")\n", " print(f\" Containerd already symlinked: {containerd_link} -> {CONTAINERD_ROOT}\")\n", "\n", " # Always ensure the boot-time restore service is up to date\n", " # (handles the case where service exists but is missing mount dependencies)\n", " _update_restore_service = True\n", " _need_restart = need_target_dirs # Restart Docker/containerd if we had to recreate dirs\n", " else:\n", " _update_restore_service = True\n", " _need_restart = True\n", "\n", " # Stop Docker AND docker.socket (socket can reactivate Docker and recreate dirs)\n", " print(\"\\n Stopping Docker and containerd for storage reconfiguration...\")\n", " subprocess.run([\"sudo\", \"systemctl\", \"stop\", \"docker.socket\"], check=False)\n", " subprocess.run([\"sudo\", \"systemctl\", \"stop\", \"docker\"], check=True)\n", " subprocess.run([\"sudo\", \"systemctl\", \"stop\", \"containerd\"], check=True)\n", "\n", " # --- Docker daemon.json ---\n", " if need_daemon_json:\n", " config[\"data-root\"] = DOCKER_DATA_ROOT\n", " new_config = json.dumps(config, indent=2)\n", " subprocess.run(\n", " f\"echo '{new_config}' | sudo tee {daemon_json}\",\n", " shell=True, check=True, capture_output=True\n", " )\n", " print(f\" Docker data-root set to {DOCKER_DATA_ROOT}\")\n", " else:\n", " print(f\" Docker data-root already set to {DOCKER_DATA_ROOT}\")\n", "\n", " # --- Volumes symlink (use ln -sfn for idempotency) ---\n", " if need_volumes_symlink:\n", " # ln -sfn: force, no-dereference (replaces existing dir/symlink atomically)\n", " subprocess.run([\"sudo\", \"rm\", \"-rf\", volumes_link], check=True)\n", " subprocess.run([\"sudo\", \"ln\", \"-sfn\", VOLUMES_DIR, volumes_link], check=True)\n", " print(f\" Created symlink: {volumes_link} -> {VOLUMES_DIR}\")\n", " else:\n", " print(f\" Volumes symlink already correct: {volumes_link} -> {VOLUMES_DIR}\")\n", "\n", " # --- Containerd ---\n", " if need_containerd:\n", " subprocess.run([\"sudo\", \"mkdir\", \"-p\", CONTAINERD_ROOT], check=True)\n", " if os.path.isdir(containerd_link) and not os.path.islink(containerd_link):\n", " # Move existing containerd data\n", " subprocess.run(f\"sudo mv {containerd_link}/* {CONTAINERD_ROOT}/ 2>/dev/null; true\",\n", " shell=True, check=False)\n", " subprocess.run([\"sudo\", \"rm\", \"-rf\", containerd_link], check=True)\n", " print(f\" Containerd data moved to {CONTAINERD_ROOT}\")\n", " elif os.path.lexists(containerd_link):\n", " subprocess.run([\"sudo\", \"rm\", \"-f\", containerd_link], check=True)\n", " subprocess.run([\"sudo\", \"ln\", \"-sfn\", CONTAINERD_ROOT, containerd_link], check=True)\n", " print(f\" Containerd symlinked: {containerd_link} -> {CONTAINERD_ROOT}\")\n", " else:\n", " print(f\" Containerd already symlinked: {containerd_link} -> {CONTAINERD_ROOT}\")\n", "\n", " # --- Install/update boot-time restore service ---\n", " # Ephemeral NVMe is wiped on instance stop/start. This systemd service\n", " # recreates the directories before Docker/containerd start so they don't crash-loop.\n", " # We use RequiresMountsFor= so the service waits for the NVMe to actually be mounted.\n", " if _update_restore_service:\n", " unit_name = \"docker-nvme-restore.service\"\n", " unit_path = f\"/etc/systemd/system/{unit_name}\"\n", "\n", " # Build After= line — include the mount unit if systemd knows about it\n", " after_targets = \"local-fs.target\"\n", " mount_unit = find_mount_unit(large_mount)\n", " if mount_unit:\n", " after_targets += f\" {mount_unit}\"\n", "\n", " unit_content = f\"\"\"[Unit]\n", "Description=Restore Docker/containerd dirs on ephemeral NVMe\n", "Before=containerd.service docker.service\n", "After={after_targets}\n", "RequiresMountsFor={large_mount}\n", "\n", "[Service]\n", "Type=oneshot\n", "ExecStart=/bin/bash -c 'mkdir -p {DOCKER_DATA_ROOT} {CONTAINERD_ROOT}'\n", "\n", "[Install]\n", "WantedBy=multi-user.target\n", "\"\"\"\n", " import tempfile\n", " with tempfile.NamedTemporaryFile(mode='w', suffix='.service', delete=False) as tmp:\n", " tmp.write(unit_content)\n", " tmp_path = tmp.name\n", " subprocess.run([\"sudo\", \"cp\", tmp_path, unit_path], check=True)\n", " os.unlink(tmp_path)\n", " subprocess.run([\"sudo\", \"systemctl\", \"daemon-reload\"], check=True)\n", " subprocess.run([\"sudo\", \"systemctl\", \"enable\", unit_name], check=True, capture_output=True)\n", " print(f\" Installed {unit_name} (restores NVMe dirs on boot, waits for mount)\")\n", "\n", " # --- Restart if needed ---\n", " if _need_restart:\n", " print(\"\\n Starting containerd and Docker...\")\n", " subprocess.run([\"sudo\", \"systemctl\", \"start\", \"containerd\"], check=True)\n", " subprocess.run([\"sudo\", \"systemctl\", \"start\", \"docker.socket\"], check=True)\n", " subprocess.run([\"sudo\", \"systemctl\", \"start\", \"docker\"], check=True)\n", "\n", " r = subprocess.run([\"docker\", \"info\", \"--format\", \"{{.DockerRootDir}}\"],\n", " capture_output=True, text=True)\n", " print(f\"\\n Docker data-root: {r.stdout.strip()}\")\n", " target = os.readlink(containerd_link) if os.path.islink(containerd_link) else containerd_link\n", " print(f\" Containerd root: {target}\")\n", " print(f\"\\n Storage configuration complete.\")\n", "else:\n", " if not STORAGE_ROOT and root_free >= MIN_ROOT_FREE_GB:\n", " print(\"Skipping storage move.\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 5. Docker Login\n", "\n", "Authenticate with the NVIDIA Container Registry (`nvcr.io`) to pull deployment images." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "import subprocess\n", "\n", "result = subprocess.run(\n", " [\"docker\", \"login\", \"nvcr.io\",\n", " \"--username\", \"$oauthtoken\",\n", " \"--password\", NGC_CLI_API_KEY],\n", " capture_output=True, text=True\n", ")\n", "if result.returncode == 0:\n", " print(\"Docker login to nvcr.io: OK\")\n", "else:\n", " print(f\"Docker login FAILED:\\n{result.stderr}\")\n", " raise RuntimeError(\"Docker login to nvcr.io failed\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 6. Get Deployment Code\n", "\n", "This cell locates the deployment code (scripts, compose files, configs). There are two ways to get it onto the server:\n", "\n", "### Option 1 — Brev Launchable (automatic)\n", "\n", "When configured as a Brev Launchable, the git repository is cloned onto the instance automatically. Set `DEPLOY_SOURCE_PATH` in Section 1 to the path where Brev placed it (typically `~/video-search-and-summarization`).\n", "\n", "### Option 2 — Manual tarball\n", "\n", "If the code isn't already on the server, create a tarball from your local checkout and copy it over:\n", "\n", "```bash\n", "# On your local machine:\n", "cd /path/to/video-search-and-summarization\n", "tar czf ~/vss-deploy-3.1.0.tar.gz --exclude='.git' .\n", "scp ~/vss-deploy-3.1.0.tar.gz @:~/\n", "\n", "# On the server:\n", "mkdir -p ~/video-search-and-summarization\n", "tar xzf ~/vss-deploy-3.1.0.tar.gz -C ~/video-search-and-summarization\n", "```\n", "\n", "Then set in **Section 1**: `DEPLOY_SOURCE_PATH = \"/home//video-search-and-summarization\"`\n", "\n", "---\n", "\n", "If `DEPLOY_SOURCE_PATH` is set, uses the code at that path directly. Otherwise, attempts to clone from GitHub (requires the repo to be accessible)." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "import subprocess, os\n", "\n", "if DEPLOY_SOURCE_PATH:\n", " # --- Use pre-extracted local repo ---\n", " REPO_DIR = DEPLOY_SOURCE_PATH\n", " print(f\"Using local deployment source: {REPO_DIR}\")\n", "else:\n", " # --- Clone from GitHub ---\n", " DEPLOY_DIR = os.path.expanduser(\"~/deployments\")\n", " REPO_DIR = os.path.join(DEPLOY_DIR, \"video-search-and-summarization\")\n", " GITHUB_REPO = \"https://github.com/NVIDIA-AI-IOT/video-search-and-summarization.git\"\n", "\n", " os.makedirs(DEPLOY_DIR, exist_ok=True)\n", "\n", " if os.path.isdir(REPO_DIR):\n", " print(f\"Repo already exists at {REPO_DIR}\")\n", " print(f\"Fetching latest and checking out {GIT_BRANCH}...\")\n", " subprocess.run([\"git\", \"fetch\", \"--all\", \"--prune\"], cwd=REPO_DIR, check=True,\n", " capture_output=True)\n", " result = subprocess.run([\"git\", \"checkout\", GIT_BRANCH], cwd=REPO_DIR,\n", " capture_output=True, text=True)\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Failed to checkout {GIT_BRANCH}:\\n{result.stderr}\")\n", " subprocess.run([\"git\", \"pull\", \"--ff-only\"], cwd=REPO_DIR, check=False,\n", " capture_output=True)\n", " else:\n", " print(f\"Cloning from GitHub (branch: {GIT_BRANCH})...\")\n", " result = subprocess.run(\n", " [\"git\", \"clone\", \"--branch\", GIT_BRANCH, \"--single-branch\", GITHUB_REPO, REPO_DIR],\n", " capture_output=True, text=True\n", " )\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Clone failed:\\n{result.stderr}\")\n", " print(\"Clone complete.\")\n", "\n", "# Validate repo structure\n", "SCRIPT_DIR = os.path.join(REPO_DIR, \"scripts\")\n", "assert os.path.isfile(os.path.join(SCRIPT_DIR, \"dev-profile.sh\")), \\\n", " f\"dev-profile.sh not found in {SCRIPT_DIR}\"\n", "assert os.path.isdir(os.path.join(REPO_DIR, \"deployments\")), \\\n", " f\"deployments/ not found in {REPO_DIR}\"\n", "\n", "# Show commit info (if it's a git repo)\n", "commit = \"(not a git repo)\"\n", "branch = \"\"\n", "if os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n", " commit = subprocess.run(\n", " [\"git\", \"log\", \"--oneline\", \"-1\"],\n", " cwd=REPO_DIR, capture_output=True, text=True\n", " ).stdout.strip()\n", " branch = subprocess.run(\n", " [\"git\", \"branch\", \"--show-current\"],\n", " cwd=REPO_DIR, capture_output=True, text=True\n", " ).stdout.strip()\n", "\n", "print(f\"\\nRepo: {REPO_DIR}\")\n", "if branch:\n", " print(f\"Branch: {branch}\")\n", "print(f\"Commit: {commit}\")\n", "print(f\"Scripts: {SCRIPT_DIR}\")\n", "print(f\"\\nContents of deployments/:\")\n", "for entry in sorted(os.listdir(os.path.join(REPO_DIR, \"deployments\"))):\n", " print(f\" {entry}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 7. Detect Network Configuration\n", "\n", "Auto-detects internal (`HOST_IP`) and external (`EXTERNAL_IP`) addresses. On NAT'd cloud instances (Brev, AWS), these are different — the internal IP is used for inter-container communication while the external IP is used for browser access.\n", "\n", "If auto-detection fails or gives the wrong result, set the overrides in Section 1." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": "import subprocess, os\n\ndef detect_internal_ip():\n \"\"\"Detect internal IP via ip route (same method as dev-profile.sh).\"\"\"\n try:\n out = subprocess.run(\n [\"bash\", \"-c\", \"ip route get 1.1.1.1 | awk '/src/ {for (i=1;i<=NF;i++) if ($i==\\\"src\\\") print $(i+1)}'\"],\n capture_output=True, text=True, timeout=5\n )\n return out.stdout.strip()\n except Exception:\n return \"\"\n\ndef detect_external_ip():\n \"\"\"Detect external IP via public service.\"\"\"\n for cmd in [\"curl -s --max-time 5 ifconfig.me\", \"curl -s --max-time 5 icanhazip.com\"]:\n try:\n out = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)\n ip = out.stdout.strip()\n if ip:\n return ip\n except Exception:\n continue\n return \"\"\n\ndef read_etc_environment():\n \"\"\"Read key=value pairs from /etc/environment (Brev sets BREV_ENV_ID there).\"\"\"\n env = {}\n try:\n with open(\"/etc/environment\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n env[key.strip()] = value.strip().strip('\"')\n except FileNotFoundError:\n pass\n return env\n\nHOST_IP = HOST_IP_OVERRIDE or detect_internal_ip()\nEXTERNAL_IP = EXTERNAL_IP_OVERRIDE or detect_external_ip()\n\nprint(f\"Internal IP (HOST_IP): {HOST_IP}\")\nprint(f\"External IP: {EXTERNAL_IP}\")\n\nif HOST_IP == EXTERNAL_IP:\n print(\"\\nInternal == External (direct connection, no NAT)\")\nelse:\n print(\"\\nNAT detected — internal and external IPs differ.\")\n print(\"The deploy script will set EXTERNAL_IP automatically.\")\n\nif not HOST_IP:\n print(\"\\nWARNING: Could not detect internal IP. Set HOST_IP_OVERRIDE in Section 1.\")\nif not EXTERNAL_IP:\n print(\"\\nWARNING: Could not detect external IP. Set EXTERNAL_IP_OVERRIDE in Section 1.\")\n\n# --- Brev Secure Links ---\n# On Brev, all browser-facing traffic routes through an nginx reverse proxy\n# on a single port (default 7777). This avoids CORS issues with Cloudflare\n# Access when each port gets its own hostname.\n# Check os.environ first, then fall back to /etc/environment (Jupyter kernels\n# may not inherit /etc/environment depending on how the notebook server starts).\n_etc_env = read_etc_environment()\nBREV_ENV_ID = os.environ.get(\"BREV_ENV_ID\") or _etc_env.get(\"BREV_ENV_ID\", \"\")\nif BREV_ENV_ID:\n # Ensure it's in os.environ so dev-profile.sh picks it up\n os.environ[\"BREV_ENV_ID\"] = BREV_ENV_ID\n proxy_port = os.environ.get(\"PROXY_PORT\", \"7777\")\n # Brev launchables create secure links with a \"0\" suffix on the port name\n # (e.g. port 7777 → \"77770-xxx.brevlab.com\"). Set BREV_LINK_PREFIX to\n # override if your setup differs (e.g. manually created links use \"7777\").\n brev_link_prefix = os.environ.get(\"BREV_LINK_PREFIX\", f\"{proxy_port}0\")\n os.environ[\"BREV_LINK_PREFIX\"] = brev_link_prefix\n brev_ui_url = f\"https://{brev_link_prefix}-{BREV_ENV_ID}.brevlab.com\"\n print(f\"\\n=== Brev Environment Detected ===\")\n print(f\" BREV_ENV_ID: {BREV_ENV_ID}\")\n print(f\" Secure link prefix: {brev_link_prefix} (set BREV_LINK_PREFIX to override)\")\n print(f\" All browser-facing URLs route through nginx proxy (port {proxy_port})\")\n print(f\" UI will be available at: {brev_ui_url}\")\nelse:\n BREV_ENV_ID = \"\" # ensure defined for later cells" }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 8. Deploy Profile\n", "\n", "This is the main deployment cell. It runs `dev-profile.sh up` with all the configuration from Section 1 and the network settings from Section 7.\n", "\n", "This will:\n", "- Generate environment files\n", "- Download required models from NGC\n", "- Pull and build Docker images\n", "- Start all containers\n", "\n", "**This cell takes 10-30 minutes** depending on network speed and whether images are cached.\n", "\n", "The cell shows a live progress summary. Full output is captured to `~/deploy_vss.log` — if something fails, check that file for details." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "import subprocess, os, re, time, datetime\n", "from IPython.display import display, clear_output, HTML\n", "\n", "LOG_FILE = os.path.expanduser(\"~/deploy_vss.log\")\n", "\n", "# Build the dev-profile.sh command\n", "# NGC_CLI_API_KEY is passed via environment (no longer a CLI flag)\n", "cmd = [\n", " \"bash\", os.path.join(SCRIPT_DIR, \"dev-profile.sh\"), \"up\",\n", " \"--profile\", PROFILE,\n", " \"--hardware-profile\", HARDWARE_PROFILE,\n", " \"--host-ip\", HOST_IP,\n", "]\n", "\n", "if EXTERNAL_IP and EXTERNAL_IP != HOST_IP:\n", " cmd += [\"--external-ip\", EXTERNAL_IP]\n", "\n", "if USE_REMOTE_LLM:\n", " cmd += [\"--use-remote-llm\"]\n", "\n", "if USE_REMOTE_VLM:\n", " cmd += [\"--use-remote-vlm\"]\n", "\n", "if PROFILE == \"alerts\":\n", " cmd += [\"--mode\", ALERTS_MODE]\n", "\n", "# Print the command\n", "display_cmd = \" \".join(cmd)\n", "print(f\"Command: {display_cmd}\")\n", "print(f\"Full log: {LOG_FILE}\\n\")\n", "\n", "# --- Phase detection and filtering ---\n", "\n", "# Lines matching these patterns are noise — suppress them\n", "SUPPRESS_PATTERNS = [\n", " re.compile(r\"^(\\s*[\\u2800-\\u28FF]|⠋|⠙|⠹|⠸|⠴|⠦|⠧|⠏)\"), # NGC spinner frames\n", " re.compile(r\"^\\s*M\\u2026|^\\s*$\"), # Truncated NGC progress fragments\n", " re.compile(r\"^\\s*#\\d+\\s+sha256:\"), # Docker buildkit layer download/extract progress\n", " re.compile(r\"^\\s*#\\d+\\s+extracting\\s\"), # Docker buildkit layer extraction\n", " re.compile(r\"^\\s*#\\d+\\s+\\.\\.\\.\"), # Docker buildkit continuation\n", " re.compile(r\"^time=.*level=warning\"), # Docker compose unset variable warnings\n", " re.compile(r\"^WARNING! Using --password\"), # Docker login warning\n", " re.compile(r\"Login Succeeded\"), # Docker login success (we print our own)\n", " re.compile(r\"^\\s*Getting files to download\"), # NGC download preamble\n", " re.compile(r\"^\\s*━\"), # NGC progress bars\n", " re.compile(r\"^\\s*[0-9a-f]{12}\\s+(Downloading|Extracting|Waiting|Verifying|Pull complete)\"), # Docker layer progress\n", "]\n", "\n", "# Lines matching these indicate phase transitions — always show\n", "PHASE_PATTERNS = [\n", " (re.compile(r\"\\[INFO\\] Generating environment\"), \"Generating environment\"),\n", " (re.compile(r\"\\[INFO\\] Downloading.*models\"), \"Downloading models from NGC\"),\n", " (re.compile(r\"\\[INFO\\].*models downloaded\"), \"Models downloaded\"),\n", " (re.compile(r\"\\[INFO\\] Logging into nvcr\"), \"Docker login\"),\n", " (re.compile(r\"\\[INFO\\] Starting docker compose\"), \"Starting Docker Compose\"),\n", " (re.compile(r\"\\[INFO\\] State up completed\"), \"Deployment complete\"),\n", "]\n", "\n", "# Image pull tracking — service-level \"Pulling \" / \"Pulled \"\n", "PULLING_RE = re.compile(r\"^\\s*Pulling\\s+(\\S+)\")\n", "PULLED_RE = re.compile(r\"^\\s*Pulled\\s+(\\S+)\")\n", "\n", "# Image build tracking — \"#N [service-name step/total] COMMAND\" / \"#N DONE Ns\"\n", "BUILD_STEP_RE = re.compile(r\"^\\s*#\\d+\\s+\\[(\\S+)\\s+(\\d+/\\d+)\\]\")\n", "BUILD_DONE_RE = re.compile(r\"^\\s*#\\d+\\s+DONE\\s+[\\d.]+s\")\n", "IMAGE_BUILT_RE = re.compile(r\"^\\s*Image\\s+(\\S+)\\s+Built\")\n", "\n", "# Container lifecycle — track creating/starting/healthy\n", "CONTAINER_RE = re.compile(r\"^\\s*Container\\s+(\\S+)\\s+(Creating|Created|Starting|Started|Healthy|Waiting|Exited.*)\")\n", "\n", "phases_seen = []\n", "images_pulling = set() # images we've seen \"Pulling\" for\n", "images_pulled = set() # images we've seen \"Pulled\" for\n", "builds = {} # service -> \"step/total\" for active builds\n", "builds_done = set() # services that finished building\n", "containers = {}\n", "errors = []\n", "start_time = time.time()\n", "\n", "def elapsed():\n", " s = int(time.time() - start_time)\n", " return f\"{s // 60}m {s % 60:02d}s\"\n", "\n", "def print_status():\n", " clear_output(wait=True)\n", " print(f\"Command: {display_cmd}\")\n", " print(f\"Full log: {LOG_FILE}\\n\")\n", "\n", " # Phases\n", " for p in phases_seen:\n", " print(f\" [done] {p}\")\n", " if phases_seen:\n", " print()\n", "\n", " # Image pull progress\n", " if images_pulling:\n", " total = len(images_pulling)\n", " done = len(images_pulled)\n", " if done < total:\n", " still_pulling = sorted(images_pulling - images_pulled)\n", " print(f\" Pulling images: {done}/{total} complete ({elapsed()})\")\n", " for img in still_pulling:\n", " print(f\" {img:<45s} pulling...\")\n", " print()\n", " else:\n", " print(f\" Pulling images: {total}/{total} complete\\n\")\n", "\n", " # Image build progress\n", " active_builds = {s: step for s, step in builds.items() if s not in builds_done}\n", " if builds:\n", " done_count = len(builds_done)\n", " total_count = len(builds)\n", " if active_builds:\n", " print(f\" Building images: {done_count}/{total_count} complete ({elapsed()})\")\n", " for svc, step in sorted(active_builds.items()):\n", " print(f\" {svc:<45s} [{step}]\")\n", " print()\n", " else:\n", " print(f\" Building images: {total_count}/{total_count} complete\\n\")\n", "\n", " # Container summary\n", " if containers:\n", " healthy = sum(1 for s in containers.values() if s == \"Healthy\")\n", " started = sum(1 for s in containers.values() if s in (\"Started\", \"Healthy\"))\n", " total = len(containers)\n", " print(f\" Containers: {started}/{total} started, {healthy}/{total} healthy ({elapsed()})\")\n", "\n", " # Show containers that aren't healthy yet\n", " pending = {n: s for n, s in containers.items() if s != \"Healthy\" and s not in (\"Exited\",)}\n", " if pending:\n", " # Only show non-trivial pending (skip init containers that exited)\n", " waiting = {n: s for n, s in pending.items() if \"Exited\" not in s}\n", " if waiting:\n", " print()\n", " for name, status in sorted(waiting.items()):\n", " print(f\" {name:<45s} {status}\")\n", " print()\n", "\n", " # Errors\n", " for e in errors:\n", " print(f\" ERROR: {e}\")\n", "\n", "# Run the process\n", "process = subprocess.Popen(\n", " cmd,\n", " stdout=subprocess.PIPE,\n", " stderr=subprocess.STDOUT,\n", " text=True,\n", " bufsize=1,\n", " cwd=SCRIPT_DIR,\n", " env={**os.environ, \"NGC_CLI_API_KEY\": NGC_CLI_API_KEY}\n", ")\n", "\n", "last_refresh = 0\n", "with open(LOG_FILE, \"w\") as log:\n", " for line in process.stdout:\n", " log.write(line)\n", " log.flush()\n", " stripped = line.rstrip()\n", "\n", " # Capture errors\n", " if \"[ERROR]\" in stripped:\n", " errors.append(stripped)\n", " print_status()\n", " continue\n", "\n", " # Track image pulls (before suppression check)\n", " m_pulling = PULLING_RE.match(stripped)\n", " if m_pulling:\n", " images_pulling.add(m_pulling.group(1))\n", " now = time.time()\n", " if now - last_refresh > 2:\n", " last_refresh = now\n", " print_status()\n", " continue\n", "\n", " m_pulled = PULLED_RE.match(stripped)\n", " if m_pulled:\n", " images_pulled.add(m_pulled.group(1))\n", " now = time.time()\n", " if now - last_refresh > 2:\n", " last_refresh = now\n", " print_status()\n", " continue\n", "\n", " # Track image builds\n", " m_build = BUILD_STEP_RE.match(stripped)\n", " if m_build:\n", " svc, step = m_build.group(1), m_build.group(2)\n", " builds[svc] = step\n", " now = time.time()\n", " if now - last_refresh > 2:\n", " last_refresh = now\n", " print_status()\n", " continue\n", "\n", " m_built = IMAGE_BUILT_RE.match(stripped)\n", " if m_built:\n", " svc = m_built.group(1)\n", " builds_done.add(svc)\n", " now = time.time()\n", " if now - last_refresh > 2:\n", " last_refresh = now\n", " print_status()\n", " continue\n", "\n", " # Suppress noise\n", " if any(p.search(stripped) for p in SUPPRESS_PATTERNS):\n", " continue\n", "\n", " # Detect phase transitions\n", " for pattern, label in PHASE_PATTERNS:\n", " if pattern.search(stripped):\n", " if label not in phases_seen:\n", " phases_seen.append(label)\n", " print_status()\n", " break\n", "\n", " # Track container lifecycle\n", " m = CONTAINER_RE.match(stripped)\n", " if m:\n", " name, status = m.group(1), m.group(2)\n", " # Normalize \"Exited (0) ...\" to \"Exited\"\n", " if status.startswith(\"Exited\"):\n", " status = \"Exited\"\n", " containers[name] = status\n", " # Refresh display at most every 2 seconds to avoid flicker\n", " now = time.time()\n", " if now - last_refresh > 2:\n", " last_refresh = now\n", " print_status()\n", "\n", "process.wait()\n", "\n", "# Final status\n", "print_status()\n", "print(\"=\" * 50)\n", "if process.returncode == 0 and not errors:\n", " print(f\"Deployment complete in {elapsed()}.\")\n", "else:\n", " print(f\"\\nDeployment FAILED (exit code {process.returncode}).\")\n", " if errors:\n", " print(f\"\\n{len(errors)} error(s) found — see above.\")\n", " print(f\"\\nFull log: {LOG_FILE}\")\n", " print(f\" View with: cat {LOG_FILE}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 9. Verify Deployment\n", "\n", "Check that all containers are running and core services are healthy. The health checks poll with retries since some services take a few minutes to fully start." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "import subprocess, time, urllib.request, urllib.error, os\n", "\n", "# Show running containers\n", "print(\"=== Running Containers ===\")\n", "subprocess.run([\"docker\", \"ps\", \"--format\", \"table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\"])\n", "print()\n", "\n", "# Determine proxy port\n", "proxy_port = os.environ.get(\"PROXY_PORT\", \"7777\")\n", "\n", "# Health check endpoints by profile\n", "checks = [\n", " (\"Proxy\", f\"http://localhost:{proxy_port}/health\"),\n", " (\"Agent\", \"http://localhost:8000/health\"),\n", " (\"VST\", \"http://localhost:30888/vst/api/v1/sensor/list\"),\n", " (\"UI\", \"http://localhost:3000\"),\n", "]\n", "if PROFILE in (\"search\", \"alerts\", \"lvs\"):\n", " checks += [\n", " (\"Elasticsearch\", \"http://localhost:9200\"),\n", " (\"Kibana\", \"http://localhost:5601/api/status\"),\n", " ]\n", "if PROFILE == \"alerts\":\n", " checks.append((\"Video Analytics API\", \"http://localhost:8081/livez\"))\n", "\n", "# Poll with retries\n", "MAX_RETRIES = 30\n", "RETRY_INTERVAL = 10\n", "results = {}\n", "\n", "print(f\"=== Health Checks (up to {MAX_RETRIES * RETRY_INTERVAL}s) ===\")\n", "pending = list(checks)\n", "\n", "for attempt in range(1, MAX_RETRIES + 1):\n", " still_pending = []\n", " for name, url in pending:\n", " try:\n", " req = urllib.request.urlopen(url, timeout=5)\n", " results[name] = f\"OK ({req.getcode()})\"\n", " except Exception:\n", " still_pending.append((name, url))\n", " pending = still_pending\n", " if not pending:\n", " break\n", " waiting = \", \".join(n for n, _ in pending)\n", " print(f\" [{attempt}/{MAX_RETRIES}] Waiting for: {waiting}\")\n", " time.sleep(RETRY_INTERVAL)\n", "\n", "for name, url in pending:\n", " results[name] = \"FAILED\"\n", "\n", "print()\n", "all_ok = True\n", "for name, status in results.items():\n", " marker = \"OK\" if \"OK\" in status else \"FAIL\"\n", " if marker == \"FAIL\":\n", " all_ok = False\n", " print(f\" {name:.<30s} {status}\")\n", "\n", "# Check perception container status (no HTTP health endpoint — DeepStream pipeline)\n", "if PROFILE == \"search\":\n", " print()\n", " r = subprocess.run(\n", " [\"docker\", \"ps\", \"--filter\", \"name=perception\", \"--format\", \"{{.Names}}: {{.Status}}\"],\n", " capture_output=True, text=True\n", " )\n", " if r.stdout.strip():\n", " print(\" Perception containers:\")\n", " for line in r.stdout.strip().splitlines():\n", " print(f\" {line}\")\n", " else:\n", " print(\" WARNING: No perception containers found (required for search profile).\")\n", " all_ok = False\n", "\n", "print()\n", "if all_ok:\n", " print(\"All services healthy.\")\n", "else:\n", " print(\"Some services failed to start. Check container logs:\")\n", " print(\" docker compose -p mdx logs \")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 10. Access the UI\n", "\n", "Once deployment is verified, open the VSS UI in your browser. Accessing the front-end will open up the chat interface where you can interact with the agent and should look like this:\n", "\n", "![VSS UI Chat Interface](images/vss_ui_chat.png)\n", "\n", "Run the cell below to generate your VSS UI URL.\n", "\n", "**On Brev:** All browser-facing traffic routes through an nginx reverse proxy on a single port (default 7777). Create **one** Brev secure link for port 7777 in the dashboard — no individual port forwarding needed. For the **alerts** and **lvs** profiles, you will also need separate secure links for Kibana and other services (see table below).\n", "\n", "**On other cloud providers:** Depending on your CSP's firewall and security group configuration, you may need to expose or forward ports to access the UI and other services from your browser. The following ports are used by VSS:\n", "\n", "| Port | Service | Profiles | Brev Secure Link |\n", "|------|---------|----------|------------------|\n", "| 7777 | Nginx proxy (consolidates UI, Agent, VST) | all | Required (primary) |\n", "| 3000 | VSS UI | all | Not needed (behind proxy) |\n", "| 8000 | VSS Agent API | all | Not needed (behind proxy) |\n", "| 30888 | VST (Video Storage Toolkit) | all | Not needed (behind proxy) |\n", "| 5601 | Kibana | search, alerts, lvs | Required (separate link) |\n", "| 6006 | Phoenix (LLM tracing/observability) | all | Optional |\n", "| 9200 | Elasticsearch | alerts, lvs | Not needed |\n", "| 8081 | Video Analytics API | alerts | Not needed |\n", "| 31000 | nvstreamer (WebRTC live view) | search, alerts | Required for live camera view |\n", "| 8554 | RTSP (if using test stream) | alerts | Not needed |\n", "\n", "**Brev summary:** For the **base** profile, create 1 secure link (port 7777). For **search**, create secure links for ports 7777, 5601, and 31000. For **alerts** or **lvs**, create secure links for ports 7777, 5601, and 31000 (alerts only). Port 6006 (Phoenix) is optional for debugging.\n", "\n", "If direct access is not possible, use SSH port forwarding or your CSP's port sharing/tunneling feature." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": "import os\n\nif BREV_ENV_ID:\n proxy_port = os.environ.get(\"PROXY_PORT\", \"7777\")\n brev_link_prefix = os.environ.get(\"BREV_LINK_PREFIX\", f\"{proxy_port}0\")\n ui_url = f\"https://{brev_link_prefix}-{BREV_ENV_ID}.brevlab.com\"\n print(f\"VSS UI (via Brev secure link): {ui_url}\")\n print()\n print(\"Setup:\")\n print(f\" 1. Ensure a Brev secure link exists for port {proxy_port}\")\n print(f\" 2. Open: {ui_url}\")\n print()\n print(\"All services (Agent API, VST, UI) are consolidated behind the proxy.\")\n print(\"No individual port forwarding is needed.\")\n if PROFILE in (\"search\", \"alerts\", \"lvs\"):\n print()\n print(f\"=== Additional Secure Links ({PROFILE}) ===\")\n print(\"Create these additional secure links in the Brev dashboard:\")\n print()\n kibana_url = f\"https://56010-{BREV_ENV_ID}.brevlab.com\"\n print(f\" Kibana (port 5601): {kibana_url}\")\n if PROFILE == \"alerts\":\n nvstreamer_url = f\"https://310000-{BREV_ENV_ID}.brevlab.com\"\n print(f\" nvstreamer (port 31000): {nvstreamer_url}\")\n phoenix_url = f\"https://60060-{BREV_ENV_ID}.brevlab.com\"\n print(f\" Phoenix (port 6006): {phoenix_url} (optional, for LLM tracing)\")\nelse:\n ui_url = f\"http://{EXTERNAL_IP or HOST_IP}:3000\"\n print(f\"VSS UI: {ui_url}\")\n print()\n print(\"If the URL is not directly accessible, use one of these methods:\")\n print()\n print(\" SSH port forwarding (works everywhere):\")\n print(f\" ssh -L 3000:localhost:3000 @{EXTERNAL_IP or HOST_IP}\")\n print(f\" Then open: http://localhost:3000\")\n print()\n print(\" VSCode Remote SSH:\")\n print(\" Connect to the instance via Remote-SSH, ports forward automatically.\")\n print()\n if PROFILE in (\"search\", \"alerts\", \"lvs\"):\n print(f\" Kibana dashboard: http://{EXTERNAL_IP or HOST_IP}:5601\")\n if PROFILE == \"alerts\":\n print(f\" nvstreamer (live view): http://{EXTERNAL_IP or HOST_IP}:31000\")\n print(f\" Phoenix (LLM tracing): http://{EXTERNAL_IP or HOST_IP}:6006\")" }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 11. Next Steps\n", "\n", "Once you can access the VSS frontend, continue the QuickStart example which involves uploading a video, engaging in Q&A, and generating a report: [Quickstart - Upload a Video](https://docs.nvidia.com/vss/3.1.0/quickstart.html#step-2-upload-a-video)\n", "\n", "You can either use your own videos for these examples or download the [VSS Sample Data from NGC](https://docs.nvidia.com/vss/3.1.0/quickstart.html#download-sample-data-from-ngc).\n", "\n", "Once you've gone through the QuickStart example, you can follow **Step 12** in this notebook to deploy different [Agent Workflows](https://docs.nvidia.com/vss/3.1.0/adding-workflows.html).\n", "\n", "**Step 13** provides instructions on stopping the deployment." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 12. Profile-Specific Next Steps\n", "\n", "Quick-start instructions for your deployed profile." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "if PROFILE == \"base\":\n", " print(\"\"\"=== Base Profile — Quick Start ===\n", "\n", "1. Open the VSS UI (see Section 10 for the URL).\n", "\n", "2. Upload a video using the \"Upload\" button in the sidebar.\n", " Supported formats: MP4, AVI, MOV. Wait for the upload to complete.\n", "\n", "3. Once uploaded, select the video and start chatting about it.\n", " Try asking: \"What is happening in this video?\"\n", "\n", "4. The agent uses the VLM to analyze video frames and answers\n", " questions about the content.\n", "\"\"\")\n", "\n", "elif PROFILE == \"search\":\n", " print(\"\"\"=== Search Profile — Quick Start ===\n", "\n", "1. Open the VSS UI and switch to the \"Search\" tab.\n", "\n", "2. Upload a video using the upload button. The video will be\n", " split into chunks and embedded for semantic search. This\n", " takes a few minutes depending on video length.\n", "\n", "3. Once processing completes, use the search bar to find moments:\n", " - \"person walking\"\n", " - \"red car\"\n", " - \"someone carrying a box\"\n", "\n", "4. Click a search result to play the matching video clip.\n", "\n", "5. You can also chat about uploaded videos in the \"Chat\" tab.\n", "\n", "Note: The perception-2d container must be running for the embedding\n", "pipeline. Check with: docker ps | grep perception\n", "\"\"\")\n", "\n", "elif PROFILE == \"alerts\":\n", " print(\"\"\"=== Alerts Profile — Quick Start ===\n", "\n", "1. Open the VSS UI. The alerts profile needs an RTSP camera stream\n", " to generate detections and alerts.\n", "\n", "2. Add a camera sensor:\n", " - Go to the \"Sensors\" or camera management section in the UI\n", " - Add your RTSP stream URL (e.g. rtsp://IP:8554/stream)\n", " - The perception pipeline will begin analyzing the stream\n", "\n", "3. View live detections:\n", " - Open the \"Alerts\" tab to see real-time alerts as they're generated\n", " - Click an alert to view the video clip with bounding boxes\n", "\n", "4. Open the \"Dashboard\" tab to see the Kibana analytics dashboard\n", " with detection statistics, timelines, and heatmaps.\n", "\n", "5. Use the \"Chat\" tab to ask questions about detected events:\n", " - \"What alerts happened in the last hour?\"\n", " - \"How many people were detected today?\"\n", "\"\"\")\n", "\n", "elif PROFILE == \"lvs\":\n", " print(\"\"\"=== LVS Profile — Quick Start ===\n", "\n", "1. Open the VSS UI and upload a video via the sidebar.\n", "\n", "2. Once uploaded, use the chat to request a report:\n", " - \"Generate a report for my_video.mp4\"\n", " - \"Summarize what happens in this video\"\n", "\n", "3. The agent analyzes the full video and generates a structured\n", " report with timeline summaries, detected events, and analytics.\n", "\n", "4. Reports are saved and accessible via the \"Reports\" section.\n", "\"\"\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 13. Stop Deployment\n", "\n", "Stop all containers **without deleting data or volumes**. Use this when you want to:\n", "- Free up GPU/memory resources temporarily\n", "- Change to a different profile (update `PROFILE` in Section 1, then re-run from Section 8)\n", "- Restart the deployment later by re-running Section 8" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import subprocess, os, glob\n", "\n", "# Find the generated.env file for the current profile\n", "env_file = None\n", "gen_pattern = os.path.join(REPO_DIR, \"deployments\", \"developer-workflow\", f\"dev-profile-{PROFILE}\", \"generated.env\")\n", "matches = glob.glob(gen_pattern)\n", "if matches:\n", " env_file = matches[0]\n", "\n", "if not env_file or not os.path.isfile(env_file):\n", " print(f\"ERROR: Could not find generated.env at {gen_pattern}\")\n", " print(\"Has the deployment been run at least once (Section 8)?\")\n", " raise FileNotFoundError(gen_pattern)\n", "\n", "print(f\"Using env file: {env_file}\")\n", "print(\"Stopping all VSS containers (preserving data and volumes)...\\n\")\n", "\n", "result = subprocess.run(\n", " [\"docker\", \"compose\", \"--env-file\", env_file,\n", " \"-f\", os.path.join(REPO_DIR, \"deployments\", \"compose.yml\"),\n", " \"-p\", \"mdx\", \"stop\"],\n", " capture_output=True, text=True,\n", " cwd=os.path.join(REPO_DIR, \"deployments\")\n", ")\n", "print(result.stdout)\n", "if result.stderr:\n", " # Filter out the harmless \"variable is not set\" warnings\n", " for line in result.stderr.splitlines():\n", " if \"is not set\" not in line:\n", " print(line)\n", "\n", "if result.returncode == 0:\n", " print(\"\\nAll containers stopped. Re-run Section 8 to start them again.\")\n", "else:\n", " print(f\"\\nStop exited with code {result.returncode}.\")\n", " print(\"You can also stop manually: docker compose -p mdx stop\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 14. Teardown\n", "\n", "Stop all containers and **delete all data** (volumes, models, data directory). Run the cell below when you want to completely remove the deployment.\n", "\n", "This runs `dev-profile.sh down` which stops containers, removes networks, and deletes the data directory. If Docker storage was moved to NVMe (Section 4), volume cleanup requires an extra step because Docker can't remove volumes whose data lives outside its data-root (the symlink trick). The cell handles this automatically." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "jupyter": { "source_hidden": true } }, "outputs": [], "source": [ "import subprocess, os, json\n", "\n", "# --- Run dev-profile.sh down ---\n", "print(\"Tearing down VSS deployment...\")\n", "process = subprocess.Popen(\n", " [\"bash\", os.path.join(SCRIPT_DIR, \"dev-profile.sh\"), \"down\"],\n", " stdout=subprocess.PIPE,\n", " stderr=subprocess.STDOUT,\n", " text=True,\n", " cwd=SCRIPT_DIR\n", ")\n", "for line in process.stdout:\n", " print(line, end=\"\")\n", "process.wait()\n", "print(f\"\\nTeardown exit code: {process.returncode}\")\n", "\n", "# --- Clean up stuck volumes ---\n", "# When Docker's data-root is on NVMe but volumes are symlinked back to root,\n", "# `docker volume rm` fails with \"unable to remove a directory outside of the\n", "# local volume root\". Fall back to sudo rm for those, then restart Docker.\n", "\n", "result = subprocess.run([\"docker\", \"volume\", \"ls\", \"-q\"], capture_output=True, text=True)\n", "leftover = result.stdout.strip().splitlines()\n", "\n", "if leftover:\n", " print(f\"\\n{len(leftover)} leftover volume(s). Cleaning up...\")\n", " need_restart = False\n", " for vol in leftover:\n", " r = subprocess.run([\"docker\", \"volume\", \"rm\", \"-f\", vol],\n", " capture_output=True, text=True)\n", " if r.returncode == 0:\n", " print(f\" removed {vol}\")\n", " else:\n", " # Symlinked volume — remove directly from /var/lib/docker/volumes\n", " vol_path = f\"/var/lib/docker/volumes/{vol}\"\n", " r2 = subprocess.run([\"sudo\", \"rm\", \"-rf\", vol_path],\n", " capture_output=True, text=True)\n", " if r2.returncode == 0:\n", " print(f\" rm'd {vol}\")\n", " need_restart = True\n", " else:\n", " print(f\" FAILED {vol}: {r2.stderr.strip()}\")\n", "\n", " if need_restart:\n", " print(\"\\n Restarting Docker to clear volume metadata...\")\n", " subprocess.run([\"sudo\", \"systemctl\", \"restart\", \"docker\"],\n", " capture_output=True, check=True)\n", "\n", " # Verify\n", " result = subprocess.run([\"docker\", \"volume\", \"ls\", \"-q\"], capture_output=True, text=True)\n", " remaining = result.stdout.strip().splitlines()\n", " if remaining:\n", " print(f\"\\n {len(remaining)} volume(s) still stuck:\")\n", " for v in remaining:\n", " print(f\" {v}\")\n", " else:\n", " print(\"\\nAll volumes cleaned up.\")\n", "else:\n", " print(\"\\nAll volumes cleaned up.\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# To remove the deployment repo from disk:\n", "# import shutil\n", "# shutil.rmtree(REPO_DIR)\n", "# print(f\"Removed {REPO_DIR}\")" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.10.12" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: scripts/dev-profile.sh ================================================ #!/bin/bash # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. script_dir="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" repo_root="$( cd -- "${script_dir}/.." &> /dev/null && pwd )" # Default values desired_state="" profile="" deployment_directory="${repo_root}/deployments" data_directory="${deployment_directory}/data-dir" hardware_profile="" host_ip="$(ip route get 1.1.1.1 | awk '/src/ {for (i=1;i<=NF;i++) if ($i=="src") print $(i+1)}')" external_ip="" mode="" mode_env="" ngc_cli_api_key="${NGC_CLI_API_KEY:-}" # NVIDIA_API_KEY and OPENAI_API_KEY from environment (optional); always written to generated.env nvidia_api_key="${NVIDIA_API_KEY:-}" openai_api_key="${OPENAI_API_KEY:-}" dry_run="false" # NIM-related defaults # LLM configuration llm_mode="" llm="" llm_device_id="" llm_base_url="" # VLM configuration vlm_mode="" vlm="" vlm_device_id="" vlm_base_url="" vlm_custom_weights="" # Optional env file paths (absolute or relative to CWD) llm_env_file="" vlm_env_file="" # Remote LLM/VLM model type (nim, openai) llm_model_type="" vlm_model_type="" # Flags to track explicitly provided options options_provided=() # Edge hardware profiles (e.g. DGX-SPARK, IGX-THOR, AGX-THOR): device ID options not accepted edge_hardware_profiles=('DGX-SPARK' 'IGX-THOR' 'AGX-THOR') # Returns the first GPU's product name from nvidia-smi (display name), or empty string if nvidia-smi fails or no GPU. function get_nvidia_smi_gpu_name() { local _name _name="$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -n1)" _name="${_name#"${_name%%[![:space:]]*}"}" _name="${_name%"${_name##*[![:space:]]}"}" echo "${_name}" } # Maps GPU product name (from nvidia-smi) to a canonical hardware type for detection. Returns OTHER if no match. # AGX-THOR and IGX-THOR both map to THOR (single canonical type). Matching is case-insensitive. function get_detected_hardware_profile() { local _gpu_name="${1}" local _gpu_lower="${_gpu_name,,}" case "${_gpu_lower}" in *h100*) echo "H100" ;; *l40s*) echo "L40S" ;; *rtx*pro*6000*blackwell*) echo "RTXPRO6000BW" ;; *gb10*) echo "DGX-SPARK" ;; *thor*) echo "THOR" ;; *) echo "OTHER" ;; esac } # Maps requested hardware_profile (CLI/env) to the same canonical type used by get_detected_hardware_profile. # AGX-THOR and IGX-THOR both map to THOR; all other profiles map to themselves. function get_canonical_hardware_profile() { local _profile="${1}" case "${_profile}" in AGX-THOR|IGX-THOR) echo "THOR" ;; *) echo "${_profile}" ;; esac } # Reverse lookup: canonical type -> slash-separated hardware_profile name(s) for display. function get_canonical_display_name() { local _canonical="${1}" case "${_canonical}" in THOR) echo "AGX-THOR / IGX-THOR" ;; *) echo "${_canonical}" ;; esac } # LLM/VLM model name to slug mapping (for paths and config lookup) function get_llm_slug() { local _name="${1}" case "${_name}" in nvidia/nvidia-nemotron-nano-9b-v2) echo "nvidia-nemotron-nano-9b-v2" ;; nvidia/NVIDIA-Nemotron-Nano-9B-v2-FP8) echo "nvidia-nemotron-nano-9b-v2-fp8" ;; nvidia/nemotron-3-nano) echo "nemotron-3-nano" ;; nvidia/llama-3.3-nemotron-super-49b-v1.5) echo "llama-3.3-nemotron-super-49b-v1.5" ;; openai/gpt-oss-20b) echo "gpt-oss-20b" ;; *) echo "" ;; esac } function get_vlm_slug() { local _name="${1}" case "${_name}" in nvidia/cosmos-reason1-7b) echo "cosmos-reason1-7b" ;; nvidia/cosmos-reason2-8b) echo "cosmos-reason2-8b" ;; Qwen/Qwen3-VL-8B-Instruct) echo "qwen3-vl-8b-instruct" ;; *) echo "" ;; esac } # Mode: accepted CLI values verification | real-time; written to MODE in env as 2d_cv | 2d_vlm function get_mode_env_value() { local _mode="${1}" case "${_mode}" in verification) echo "2d_cv" ;; real-time) echo "2d_vlm" ;; *) echo "" ;; esac } function get_mode_display_value() { local _env_val="${1}" case "${_env_val}" in 2d_cv) echo "verification" ;; 2d_vlm) echo "real-time" ;; *) echo "${_env_val}" ;; esac } # Gets model name from remote API endpoint (works for both LLM and VLM) # Arguments: base_url (e.g., http://localhost:30082/v1) # Returns: model name from the /models endpoint, or empty string on error function get_remote_model_name() { local _base_url="${1}" local _model_name _curl_exit_code _model_name="$(curl -s -f "${_base_url}/v1/models" 2>/dev/null | jq -r '.data[0].id // empty' 2>/dev/null)" _curl_exit_code=$? if [[ ${_curl_exit_code} -ne 0 ]] || [[ -z "${_model_name}" ]]; then echo "[WARNING] Failed to retrieve model name from ${_base_url}/v1/models" >&2 echo "" return 1 fi echo "${_model_name}" return 0 } function get_env_value() { local _env_file="${1}" local _var_name="${2}" local _val if [[ -f "${_env_file}" ]]; then _val="$(grep "^${_var_name}=" "${_env_file}" 2>/dev/null | cut -d'=' -f2- | head -1)" _val="${_val#[\'\"]}" _val="${_val%[\'\"]}" echo "${_val}" fi } # Resolve path to absolute (relative paths are relative to current working directory). # Outputs normalized absolute path, or empty on error. function resolve_abs_path() { local p="${1}" [[ -z "${p}" ]] && { echo ""; return; } if [[ "${p}" != /* ]]; then p="$(pwd)/${p}" fi local dir base dir="$(dirname "${p}")" base="$(basename "${p}")" if [[ -d "${dir}" ]]; then echo "$(cd "${dir}" && pwd)/${base}" else echo "${p}" fi } function mask_secret() { local _secret="${1}" local _len="${#_secret}" if [[ ${_len} -le 6 ]]; then echo "******" else local _first="${_secret:0:3}" local _last="${_secret: -3}" local _middle_len=$((_len - 6)) local _mask=$(printf '%*s' "${_middle_len}" '' | tr ' ' '*') echo "${_first}${_mask}${_last}" fi } # Apply VSS kernel settings (IPv6 disable, TCP buffer sizes). Persistent across reboots via /etc/sysctl.d/99-vss.conf. function set_vss_linux_kernel_settings() { sudo mkdir -p /etc/sysctl.d sudo bash -c "printf '%s\n' \ 'net.ipv6.conf.all.disable_ipv6 = 1' \ 'net.ipv6.conf.default.disable_ipv6 = 1' \ 'net.ipv6.conf.lo.disable_ipv6 = 1' \ 'net.core.rmem_max = 5242880' \ 'net.core.wmem_max = 5242880' \ 'net.ipv4.tcp_rmem = 4096 87380 16777216' \ 'net.ipv4.tcp_wmem = 4096 65536 16777216' \ > /etc/sysctl.d/99-vss.conf" sudo sysctl --system } function usage() { echo "Usage: ${0} (up|down) [options]" echo " or: ${0} (-h|--help)" echo "" echo "Positional arguments:" echo " desired-state up or down" echo "" echo "NOTE: The following are read from the environment (no CLI options):" echo " • NGC_CLI_API_KEY — required for 'up'" echo " • NVIDIA_API_KEY — optional; used for accessing remote LLM/VLM endpoints" echo " • OPENAI_API_KEY — optional; used for accessing remote LLM/VLM endpoints" echo " • LLM_ENDPOINT_URL — optional; when --use-remote-llm is passed, used as LLM base URL" echo " • VLM_ENDPOINT_URL — optional; when --use-remote-vlm is passed, used as VLM base URL" echo " • VLM_CUSTOM_WEIGHTS — optional; when --use-remote-vlm is not passed: absolute path to custom weights dir; when --use-remote-vlm is passed, ignored" echo "" echo "Options for 'up':" echo " -p, --profile [REQUIRED] Profile." echo " • One of:" echo " - base" echo " - lvs" echo " - search" echo " - alerts" echo " • Required for 'up'" echo " -H, --hardware-profile Hardware profile." echo " • One of:" echo " - H100" echo " - L40S" echo " - RTXPRO6000BW" echo " - DGX-SPARK" echo " - IGX-THOR" echo " - AGX-THOR" echo " - OTHER" echo " • DGX-SPARK, IGX-THOR, and AGX-THOR only valid when profile is base or alerts" echo " • DGX-SPARK, IGX-THOR, AGX-THOR: --llm-device-id, --vlm-device-id not accepted" echo " -i, --host-ip Host IP." echo " • Default: primary IP from ip route" echo " -e, --external-ip Externally accessible IP." echo " -m, --mode Mode for alerts profile." echo " • One of:" echo " - verification" echo " - real-time" echo " • Required when profile is alerts" echo "" echo " --llm LLM model name." echo " • One of (local):" echo " - nvidia/nvidia-nemotron-nano-9b-v2" echo " - nvidia/NVIDIA-Nemotron-Nano-9B-v2-FP8" echo " - nvidia/nemotron-3-nano" echo " - nvidia/llama-3.3-nemotron-super-49b-v1.5" echo " - openai/gpt-oss-20b" echo " • When --use-remote-llm is passed, any model name can be passed" echo " --llm-device-id LLM device ID." echo " • Not allowed when --use-remote-llm is passed" echo " • DGX-SPARK, IGX-THOR, AGX-THOR: not accepted" echo " --use-remote-llm Use remote LLM; base URL taken from host env LLM_ENDPOINT_URL." echo " --llm-model-type LLM backend type when --use-remote-llm is passed: nim or openai." echo " --llm-env-file Path to LLM env file. Absolute or relative to CWD." echo " • Not allowed when --use-remote-llm is passed" echo "" echo " --vlm VLM model name." echo " • One of (local):" echo " - nvidia/cosmos-reason1-7b" echo " - nvidia/cosmos-reason2-8b" echo " - Qwen/Qwen3-VL-8B-Instruct" echo " • Not allowed for profile=search" echo " • Not accepted for profile=alerts or base on IGX-THOR or AGX-THOR" echo " • When --use-remote-vlm is passed, any model name can be passed" echo " --vlm-device-id VLM device ID." echo " • Not allowed when --use-remote-vlm is passed" echo " • Not allowed for profile=search" echo " • DGX-SPARK, IGX-THOR, AGX-THOR: not accepted" echo " --use-remote-vlm Use remote VLM; base URL taken from host env VLM_ENDPOINT_URL." echo " • Optional for profile=search; not accepted for profile=alerts or base on IGX-THOR or AGX-THOR" echo " --vlm-model-type VLM backend type when --use-remote-vlm is passed: nim or openai." echo " --vlm-env-file Path to VLM env file. Absolute or relative to CWD." echo " • Not allowed when --use-remote-vlm is passed or profile=search" echo " • Not accepted for profile=alerts or base on IGX-THOR or AGX-THOR" echo "" echo "Options for 'up' and 'down':" echo " -d, --dry-run print commands without executing them" echo " -h, --help show this help message" } function contains_element() { local _element _ref_array _array_element _element="${1}" _ref_array=("${@:2}") for _array_element in "${_ref_array[@]}" do if [[ "${_element}" == "${_array_element}" ]]; then return 0 fi done return 1 } function validate_args() { local _args _valid_args _valid_desired_states _valid_profiles _valid_modes _all_good _args=("${@}") _all_good=0 _valid_args=$(getopt -q -o p:H:i:e:m:dh --long profile:,hardware-profile:,host-ip:,external-ip:,mode:,llm-device-id:,vlm-device-id:,use-remote-llm,use-remote-vlm,llm:,vlm:,llm-model-type:,vlm-model-type:,llm-env-file:,vlm-env-file:,dry-run,help -- "${_args[@]}") if [[ $? -ne 0 ]]; then echo "[ERROR] Invalid usage: ${_args[*]}" ((_all_good++)) else eval set -- "${_valid_args}" # Check for help flag first while true; do case "${1}" in -h | --help) usage; exit 0 ;; --) shift; break ;; *) shift ;; esac done # Get positional argument (desired-state) if [[ -z "${1}" ]]; then echo "[ERROR] desired-state is required" ((_all_good++)) else _valid_desired_states=('up' 'down') if ! contains_element "${1}" "${_valid_desired_states[@]}"; then echo "[ERROR] Invalid desired-state: ${1}. Must be 'up' or 'down'" ((_all_good++)) fi fi fi if [[ _all_good -gt 0 ]]; then echo "" usage exit 1 fi } function process_args() { local _args _valid_args _valid_profiles _valid_modes _all_good _args=("${@}") _all_good=0 _valid_args=$(getopt -q -o p:H:i:e:m:dh --long profile:,hardware-profile:,host-ip:,external-ip:,mode:,llm-device-id:,vlm-device-id:,use-remote-llm,use-remote-vlm,llm:,vlm:,llm-model-type:,vlm-model-type:,llm-env-file:,vlm-env-file:,dry-run,help -- "${_args[@]}") eval set -- "${_valid_args}" # Parse options while true; do case "${1}" in -p | --profile) shift profile="${1}" options_provided+=("profile") shift ;; -H | --hardware-profile) shift hardware_profile="${1}" options_provided+=("hardware-profile") shift ;; -i | --host-ip) shift host_ip="${1}" options_provided+=("host-ip") shift ;; -e | --external-ip) shift external_ip="${1}" options_provided+=("external-ip") shift ;; -m | --mode) shift mode="${1}" options_provided+=("mode") shift ;; --llm-device-id) shift llm_device_id="${1}" options_provided+=("llm-device-id") shift ;; --vlm-device-id) shift vlm_device_id="${1}" options_provided+=("vlm-device-id") shift ;; --use-remote-llm) llm_base_url="${LLM_ENDPOINT_URL:-}" options_provided+=("use-remote-llm") shift ;; --use-remote-vlm) vlm_base_url="${VLM_ENDPOINT_URL:-}" options_provided+=("use-remote-vlm") shift ;; --llm) shift llm="${1}" options_provided+=("llm") shift ;; --vlm) shift vlm="${1}" options_provided+=("vlm") shift ;; --llm-model-type) shift llm_model_type="${1}" options_provided+=("llm-model-type") shift ;; --vlm-model-type) shift vlm_model_type="${1}" options_provided+=("vlm-model-type") shift ;; --llm-env-file) shift llm_env_file="${1}" options_provided+=("llm-env-file") shift ;; --vlm-env-file) shift vlm_env_file="${1}" options_provided+=("vlm-env-file") shift ;; -d | --dry-run) dry_run="true" options_provided+=("dry-run") shift ;; -h | --help) shift ;; --) shift break ;; esac done # Get positional argument desired_state="${1}" # Validation based on desired-state if [[ "${desired_state}" == "down" ]]; then # Only dry-run option is allowed for 'down' for _opt in "${options_provided[@]}"; do if [[ "${_opt}" != "dry-run" ]]; then echo "[ERROR] Only --dry-run option is allowed for desired-state 'down'" echo "[ERROR] Invalid option provided: ${_opt}" ((_all_good++)) break fi done elif [[ "${desired_state}" == "up" ]]; then # Validate required options for 'up' if ! contains_element "profile" "${options_provided[@]}"; then echo "[ERROR] --profile is required for desired-state 'up'" ((_all_good++)) fi if [[ -z "${ngc_cli_api_key}" ]]; then echo "[ERROR] NGC_CLI_API_KEY is required for desired-state 'up'" ((_all_good++)) fi # Validate profile value _valid_profiles=('base' 'lvs' 'search' 'alerts') if [[ -n "${profile}" ]]; then if ! contains_element "${profile}" "${_valid_profiles[@]}"; then echo "[ERROR] Invalid profile: ${profile}. Must be one of: base, lvs, search, alerts" ((_all_good++)) fi fi # Fail fast: profile .env must exist for 'up' if [[ -n "${profile}" ]] && contains_element "${profile}" "${_valid_profiles[@]}"; then local _profile_env_check="${deployment_directory}/developer-workflow/dev-profile-${profile}/.env" if [[ ! -f "${_profile_env_check}" ]]; then echo "[ERROR] Profile .env file not found: ${_profile_env_check}" ((_all_good++)) fi fi # Only run profile-based lookups and subsequent validation when profile is valid and .env exists. # This avoids cascading errors (e.g. invalid hardware-profile, invalid LLM/VLM configuration) when profile validation already failed. if [[ -n "${profile}" ]] && contains_element "${profile}" "${_valid_profiles[@]}" && [[ -f "${deployment_directory}/developer-workflow/dev-profile-${profile}/.env" ]]; then # Populate from profile .env when not provided by user (only after .env existence is verified) local _profile_env="${deployment_directory}/developer-workflow/dev-profile-${profile}/.env" if ! contains_element "hardware-profile" "${options_provided[@]}"; then hardware_profile="$(get_env_value "${_profile_env}" "HARDWARE_PROFILE")" fi if ! contains_element "llm-device-id" "${options_provided[@]}"; then llm_device_id="$(get_env_value "${_profile_env}" "LLM_DEVICE_ID")" fi if ! contains_element "vlm-device-id" "${options_provided[@]}"; then vlm_device_id="$(get_env_value "${_profile_env}" "VLM_DEVICE_ID")" fi local _fixed_shared_raw _fixed_shared_norm _reserved_raw _reserved_norm _fixed_shared_raw="$(get_env_value "${_profile_env}" "FIXED_SHARED_DEVICE_IDS")" _fixed_shared_raw="${_fixed_shared_raw// /}" _fixed_shared_norm=",${_fixed_shared_raw}," _reserved_raw="$(get_env_value "${_profile_env}" "RESERVED_DEVICE_IDS")" _reserved_raw="${_reserved_raw// /}" _reserved_norm=",${_reserved_raw}," if ! contains_element "llm-model-type" "${options_provided[@]}"; then llm_model_type="$(get_env_value "${_profile_env}" "LLM_MODEL_TYPE")" fi if ! contains_element "vlm-model-type" "${options_provided[@]}"; then vlm_model_type="$(get_env_value "${_profile_env}" "VLM_MODEL_TYPE")" fi # Validate hardware profile value (from profile .env or --hardware-profile) _valid_hardware_profiles=('H100' 'L40S' 'RTXPRO6000BW' 'DGX-SPARK' 'IGX-THOR' 'AGX-THOR' 'OTHER') if ! contains_element "${hardware_profile}" "${_valid_hardware_profiles[@]}"; then echo "[ERROR] Invalid hardware-profile: ${hardware_profile}. Must be one of: H100, L40S, RTXPRO6000BW, DGX-SPARK, IGX-THOR, AGX-THOR, OTHER" ((_all_good++)) fi # Fail fast: requested hardware_profile must match detected GPU (from nvidia-smi display name). # Both sides use canonical types (AGX-THOR and IGX-THOR map to THOR for comparison). # Set SKIP_HARDWARE_CHECK=true to skip (e.g. in CI/tests without matching GPU). if [[ -n "${hardware_profile}" ]] && [[ "${SKIP_HARDWARE_CHECK,,}" != "true" ]]; then local _gpu_name _detected_canonical _gpu_name="$(get_nvidia_smi_gpu_name)" if [[ -z "${_gpu_name}" ]]; then echo "[ERROR] Hardware profile '${hardware_profile}' does not match detected hardware (no NVIDIA GPU detected)." ((_all_good++)) elif _detected_canonical="$(get_detected_hardware_profile "${_gpu_name}")" && [[ "$(get_canonical_hardware_profile "${hardware_profile}")" != "${_detected_canonical}" ]]; then echo "[ERROR] Hardware profile '${hardware_profile}' does not match detected hardware '$(get_canonical_display_name "${_detected_canonical}")'." ((_all_good++)) fi fi # DGX-SPARK, IGX-THOR, AGX-THOR (edge_hardware_profiles): only valid for base and alerts; device ID options not accepted if contains_element "${hardware_profile}" "${edge_hardware_profiles[@]}"; then if [[ "${profile}" != "base" ]] && [[ "${profile}" != "alerts" ]]; then echo "[ERROR] Hardware profile '${hardware_profile}' is only valid for profile base or alerts, not '${profile}'" ((_all_good++)) fi if contains_element "llm-device-id" "${options_provided[@]}"; then echo "[ERROR] --llm-device-id is not accepted for hardware profile '${hardware_profile}'" ((_all_good++)) fi if contains_element "vlm-device-id" "${options_provided[@]}"; then echo "[ERROR] --vlm-device-id is not accepted for hardware profile '${hardware_profile}'" ((_all_good++)) fi llm_device_id="0" vlm_device_id="0" fi # Alerts or base profile on IGX-THOR or AGX-THOR: VLM options are not accepted (VLM is fixed for this configuration). # Note: --vlm-device-id is already rejected for all IGX-THOR/AGX-THOR/DGX-SPARK in the edge_hardware_profiles block above. if ([[ "${hardware_profile}" == "IGX-THOR" ]] || [[ "${hardware_profile}" == "AGX-THOR" ]]) && ([[ "${profile}" == "alerts" ]] || [[ "${profile}" == "base" ]]); then if contains_element "use-remote-vlm" "${options_provided[@]}"; then echo "[ERROR] --use-remote-vlm is not accepted for ${profile} profile with hardware profile ${hardware_profile}" ((_all_good++)) fi if contains_element "vlm" "${options_provided[@]}"; then echo "[ERROR] --vlm is not accepted for ${profile} profile with hardware profile ${hardware_profile}" ((_all_good++)) fi if contains_element "vlm-model-type" "${options_provided[@]}"; then echo "[ERROR] --vlm-model-type is not accepted for ${profile} profile with hardware profile ${hardware_profile}" ((_all_good++)) fi if contains_element "vlm-env-file" "${options_provided[@]}"; then echo "[ERROR] --vlm-env-file is not accepted for ${profile} profile with hardware profile ${hardware_profile}" ((_all_good++)) fi fi # Derive LLM mode: remote when --use-remote-llm is passed; else local_shared if device ID is in RESERVED_DEVICE_IDS, FIXED_SHARED_DEVICE_IDS, or (VLM not remote and equals VLM_DEVICE_ID), else local. Do not use vlm_device_id when VLM is remote. if [[ -n "${llm_base_url}" ]] || contains_element "use-remote-llm" "${options_provided[@]}"; then llm_mode="remote" else if [[ -n "${llm_device_id}" ]]; then if [[ "${_reserved_norm}" == *",${llm_device_id},"* ]] || [[ "${_fixed_shared_norm}" == *",${llm_device_id},"* ]]; then llm_mode="local_shared" elif [[ -z "${vlm_base_url}" ]] && [[ "${profile}" != "search" ]] && [[ "${llm_device_id}" == "${vlm_device_id}" ]]; then llm_mode="local_shared" else llm_mode="local" fi else llm_mode="local" fi fi # Derive VLM mode: remote when --use-remote-vlm is passed or profile=search; else local_shared if device ID is in RESERVED_DEVICE_IDS, FIXED_SHARED_DEVICE_IDS, or (LLM not remote and equals LLM_DEVICE_ID), else local. Do not use llm_device_id when LLM is remote. if [[ -n "${vlm_base_url}" ]] || [[ "${profile}" == "search" ]] || contains_element "use-remote-vlm" "${options_provided[@]}"; then vlm_mode="remote" else if [[ -n "${vlm_device_id}" ]]; then if [[ "${_reserved_norm}" == *",${vlm_device_id},"* ]] || [[ "${_fixed_shared_norm}" == *",${vlm_device_id},"* ]]; then vlm_mode="local_shared" elif [[ "${llm_mode}" != "remote" ]] && [[ "${vlm_device_id}" == "${llm_device_id}" ]]; then vlm_mode="local_shared" else vlm_mode="local" fi else vlm_mode="local" fi fi # When VLM is not remote, use host env VLM_CUSTOM_WEIGHTS if set; when remote, ignore it (do not set in generated.env). if [[ "${vlm_mode}" != "remote" ]]; then vlm_custom_weights="${VLM_CUSTOM_WEIGHTS:-}" else vlm_custom_weights="" fi # Validate mode based on profile if [[ "${profile}" == "alerts" ]]; then if ! contains_element "mode" "${options_provided[@]}" || [[ -z "${mode}" ]]; then echo "[ERROR] For alerts profile, --mode is required. Must be one of: verification, real-time" ((_all_good++)) else _valid_modes=('verification' 'real-time') if ! contains_element "${mode}" "${_valid_modes[@]}"; then echo "[ERROR] Invalid mode: ${mode}. For alerts profile, must be one of: verification, real-time" ((_all_good++)) else mode_env="$(get_mode_env_value "${mode}")" fi fi else # For non-alert profiles, mode option is not allowed if contains_element "mode" "${options_provided[@]}"; then echo "[ERROR] --mode is only accepted when profile is 'alerts'" ((_all_good++)) fi fi # Validate LLM and VLM mode values (from profile) _valid_mode_values=('local_shared' 'local' 'remote') if ! contains_element "${llm_mode}" "${_valid_mode_values[@]}"; then echo "[ERROR] Invalid LLM configuration: ${llm_mode}. Must be one of: local_shared, local, remote" ((_all_good++)) fi # VLM not used for search profile; validate only for other profiles if [[ "${profile}" != "search" ]]; then if ! contains_element "${vlm_mode}" "${_valid_mode_values[@]}"; then echo "[ERROR] Invalid VLM configuration: ${vlm_mode}. Must be one of: local_shared, local, remote" ((_all_good++)) fi fi # L40S: neither LLM nor VLM may use local_shared (device ID cannot be shared with other services) if [[ "${hardware_profile}" == "L40S" ]]; then if [[ "${llm_mode}" == "local_shared" ]]; then echo "[ERROR] On L40S, the device ID for the LLM cannot be shared with other services" ((_all_good++)) fi if [[ "${profile}" != "search" ]] && [[ "${vlm_mode}" == "local_shared" ]]; then echo "[ERROR] On L40S, the device ID for the VLM cannot be shared with other services" ((_all_good++)) fi fi # Device IDs must not be in profile RESERVED_DEVICE_IDS (comma-separated list; may be empty). # Exception: DGX-SPARK, IGX-THOR, AGX-THOR are exempt (device ID options not accepted). if ! contains_element "${hardware_profile}" "${edge_hardware_profiles[@]}"; then if [[ -n "${profile}" ]] && [[ -f "${deployment_directory}/developer-workflow/dev-profile-${profile}/.env" ]]; then local _profile_env_reserved="${deployment_directory}/developer-workflow/dev-profile-${profile}/.env" local _reserved_raw _reserved_raw="$(get_env_value "${_profile_env_reserved}" "RESERVED_DEVICE_IDS")" _reserved_raw="${_reserved_raw// /}" # normalize: remove spaces so "0, 1" matches id "0" and "1" local _reserved_norm=",${_reserved_raw}," if [[ "${llm_mode}" != "remote" ]] && [[ -n "${llm_device_id}" ]]; then if [[ "${_reserved_norm}" == *",${llm_device_id},"* ]]; then echo "[ERROR] Device ID ${llm_device_id} is reserved and cannot be assigned to LLM or VLM for this profile" ((_all_good++)) fi fi if [[ "${profile}" != "search" ]] && [[ "${vlm_mode}" != "remote" ]] && [[ -n "${vlm_device_id}" ]]; then if [[ "${_reserved_norm}" == *",${vlm_device_id},"* ]]; then echo "[ERROR] Device ID ${vlm_device_id} is reserved and cannot be assigned to LLM or VLM for this profile" ((_all_good++)) fi fi fi fi # Resolve and validate optional env file paths (must exist; stored as absolute) if [[ -n "${llm_env_file}" ]]; then llm_env_file="$(resolve_abs_path "${llm_env_file}")" if [[ ! -f "${llm_env_file}" ]]; then echo "[ERROR] LLM env file not found: ${llm_env_file}" ((_all_good++)) fi fi if [[ -n "${vlm_env_file}" ]]; then vlm_env_file="$(resolve_abs_path "${vlm_env_file}")" if [[ ! -f "${vlm_env_file}" ]]; then echo "[ERROR] VLM env file not found: ${vlm_env_file}" ((_all_good++)) fi fi # ===== LLM Validations ===== # Validate LLM options when --use-remote-llm is passed if [[ "${llm_mode}" == "remote" ]]; then if contains_element "llm-device-id" "${options_provided[@]}"; then echo "[ERROR] --llm-device-id is not allowed when --use-remote-llm is passed" ((_all_good++)) fi if contains_element "llm-env-file" "${options_provided[@]}"; then echo "[ERROR] --llm-env-file is not allowed when --use-remote-llm is passed" ((_all_good++)) fi if [[ -z "${llm_base_url}" ]]; then echo "[ERROR] LLM_ENDPOINT_URL must be set when --use-remote-llm is passed" ((_all_good++)) fi # When --use-remote-llm is passed, validate llm-model-type value if provided if contains_element "llm-model-type" "${options_provided[@]}" && [[ -n "${llm_model_type}" ]]; then _valid_llm_types=('nim' 'openai') if ! contains_element "${llm_model_type}" "${_valid_llm_types[@]}"; then echo "[ERROR] Invalid llm-model-type: ${llm_model_type}. Must be one of: nim, openai" ((_all_good++)) fi fi else # Validate LLM model name if provided (only for non-remote modes; known names map to a slug) if contains_element "llm" "${options_provided[@]}"; then if [[ -z "$(get_llm_slug "${llm}")" ]]; then echo "[ERROR] Invalid LLM model name: ${llm}. Must be one of: nvidia/nvidia-nemotron-nano-9b-v2, nvidia/NVIDIA-Nemotron-Nano-9B-v2-FP8, nvidia/nemotron-3-nano, nvidia/llama-3.3-nemotron-super-49b-v1.5, openai/gpt-oss-20b" ((_all_good++)) fi fi if contains_element "llm-model-type" "${options_provided[@]}"; then echo "[ERROR] --llm-model-type is only allowed when --use-remote-llm is passed" ((_all_good++)) fi fi # ===== VLM Validations ===== # Validate VLM options for search profile (search uses remote VLM) if [[ "${profile}" == "search" ]]; then if contains_element "vlm-device-id" "${options_provided[@]}"; then echo "[ERROR] --vlm-device-id is not allowed for search profile" ((_all_good++)) fi if contains_element "vlm" "${options_provided[@]}"; then echo "[ERROR] --vlm is not allowed for search profile" ((_all_good++)) fi if contains_element "vlm-env-file" "${options_provided[@]}"; then echo "[ERROR] --vlm-env-file is not allowed for search profile" ((_all_good++)) fi fi # Validate VLM options when --use-remote-vlm is passed if [[ "${vlm_mode}" == "remote" ]]; then if contains_element "vlm-device-id" "${options_provided[@]}"; then echo "[ERROR] --vlm-device-id is not allowed when --use-remote-vlm is passed" ((_all_good++)) fi if contains_element "vlm-env-file" "${options_provided[@]}"; then echo "[ERROR] --vlm-env-file is not allowed when --use-remote-vlm is passed" ((_all_good++)) fi if [[ -z "${vlm_base_url}" ]] && [[ "${profile}" != "search" ]]; then echo "[ERROR] VLM_ENDPOINT_URL must be set when --use-remote-vlm is passed" ((_all_good++)) fi # When --use-remote-vlm is passed, validate vlm-model-type value if provided if contains_element "vlm-model-type" "${options_provided[@]}" && [[ -n "${vlm_model_type}" ]]; then _valid_vlm_types=('nim' 'openai') if ! contains_element "${vlm_model_type}" "${_valid_vlm_types[@]}"; then echo "[ERROR] Invalid vlm-model-type: ${vlm_model_type}. Must be one of: nim, openai" ((_all_good++)) fi fi else if contains_element "vlm-model-type" "${options_provided[@]}"; then echo "[ERROR] --vlm-model-type is only allowed when --use-remote-vlm is passed" ((_all_good++)) fi if contains_element "vlm" "${options_provided[@]}"; then if [[ -z "$(get_vlm_slug "${vlm}")" ]]; then echo "[ERROR] Invalid VLM model name: ${vlm}. Must be one of: nvidia/cosmos-reason1-7b, nvidia/cosmos-reason2-8b, Qwen/Qwen3-VL-8B-Instruct" ((_all_good++)) fi fi fi # Fail fast: VLM_CUSTOM_WEIGHTS must be an absolute path and the directory must exist (even in dry-run) if [[ "${profile}" != "search" ]] && [[ "${vlm_mode}" != "remote" ]]; then if [[ -n "${vlm_custom_weights}" ]]; then if [[ "${vlm_custom_weights}" != /* ]]; then echo "[ERROR] VLM_CUSTOM_WEIGHTS must be an absolute path: ${vlm_custom_weights}" ((_all_good++)) elif [[ ! -d "${vlm_custom_weights}" ]]; then echo "[ERROR] Specified VLM custom weights path does not exist: ${vlm_custom_weights}" ((_all_good++)) fi fi fi fi # end: only run profile-based lookups when profile is valid and .env exists fi if [[ _all_good -gt 0 ]]; then echo "" usage exit 1 fi } function print_args() { echo "=== Captured Arguments ===" echo "desired-state: ${desired_state}" echo "deployment-directory: ${deployment_directory}" echo "data-directory: ${data_directory}" echo "dry-run: ${dry_run}" if [[ "${desired_state}" == "up" ]]; then echo "profile: ${profile}" echo "host-ip: ${host_ip}" if [[ -n "${external_ip}" ]]; then echo "external-ip: ${external_ip}" fi echo "ngc-cli-api-key: $(mask_secret "${ngc_cli_api_key}")" local _env_file="${deployment_directory}/developer-workflow/dev-profile-${profile}/.env" local _llm_mode="${llm_mode:-$(get_env_value "${_env_file}" "LLM_MODE")}" local _vlm_mode="${vlm_mode:-$(get_env_value "${_env_file}" "VLM_MODE")}" echo "hardware-profile: ${hardware_profile:-$(get_env_value "${_env_file}" "HARDWARE_PROFILE")}" if [[ "${profile}" == "alerts" ]]; then echo "mode: ${mode:-$(get_mode_display_value "$(get_env_value "${_env_file}" "MODE")")}" fi echo "llm-mode: ${_llm_mode}" local _llm_model if [[ "${_llm_mode}" == "remote" ]] && [[ -n "${llm_base_url}" ]]; then if [[ -n "${llm}" ]]; then _llm_model="${llm}" else _llm_model="$(get_remote_model_name "${llm_base_url}")" fi else _llm_model="${llm:-$(get_env_value "${_env_file}" "LLM_NAME")}" fi echo "llm: ${_llm_model}" if [[ "${_llm_mode}" != "remote" ]]; then local _llm_device_id="${llm_device_id:-$(get_env_value "${_env_file}" "LLM_DEVICE_ID")}" echo "llm-device-id: ${_llm_device_id}" fi if [[ "${_llm_mode}" == "remote" ]]; then local _llm_base_url="${llm_base_url:-$(get_env_value "${_env_file}" "LLM_BASE_URL")}" echo "llm-base-url: ${_llm_base_url}" local _llm_model_type="${llm_model_type:-$(get_env_value "${_env_file}" "LLM_MODEL_TYPE")}" if [[ -n "${_llm_model_type}" ]]; then echo "llm-model-type: ${_llm_model_type}" fi fi if [[ -n "${llm_env_file}" ]]; then echo "llm-env-file: ${llm_env_file}" fi if [[ "${profile}" != "search" ]]; then echo "vlm-mode: ${_vlm_mode}" local _vlm_model if [[ "${_vlm_mode}" == "remote" ]] && [[ -n "${vlm_base_url}" ]]; then if [[ -n "${vlm}" ]]; then _vlm_model="${vlm}" else _vlm_model="$(get_remote_model_name "${vlm_base_url}")" fi else _vlm_model="${vlm:-$(get_env_value "${_env_file}" "VLM_NAME")}" fi echo "vlm: ${_vlm_model}" if [[ "${_vlm_mode}" != "remote" ]]; then local _vlm_device_id="${vlm_device_id:-$(get_env_value "${_env_file}" "VLM_DEVICE_ID")}" echo "vlm-device-id: ${_vlm_device_id}" fi if [[ "${_vlm_mode}" == "remote" ]]; then local _vlm_base_url="${vlm_base_url:-$(get_env_value "${_env_file}" "VLM_BASE_URL")}" echo "vlm-base-url: ${_vlm_base_url}" local _vlm_model_type="${vlm_model_type:-$(get_env_value "${_env_file}" "VLM_MODEL_TYPE")}" if [[ -n "${_vlm_model_type}" ]]; then echo "vlm-model-type: ${_vlm_model_type}" fi fi if [[ -n "${vlm_custom_weights}" ]]; then echo "vlm-custom-weights: ${vlm_custom_weights}" fi if [[ -n "${vlm_env_file}" ]]; then echo "vlm-env-file: ${vlm_env_file}" fi fi fi if [[ -n "${nvidia_api_key}" ]]; then echo "nvidia-api-key: $(mask_secret "${nvidia_api_key}")" fi if [[ -n "${openai_api_key}" ]]; then echo "openai-api-key: $(mask_secret "${openai_api_key}")" fi echo "==========================" } function state_up() { local _profile_dir _source_env _generated_env _profile_dir="${deployment_directory}/developer-workflow/dev-profile-${profile}" _source_env="${_profile_dir}/.env" _generated_env="${_profile_dir}/generated.env" echo "[INFO] Generating environment file for profile '${profile}'..." # Check if source .env exists if [[ ! -f "${_source_env}" ]]; then echo "[ERROR] Source .env file not found: ${_source_env}" exit 1 fi # Copy source .env to generated.env cp "${_source_env}" "${_generated_env}" echo "[INFO] Copied ${_source_env} to ${_generated_env}" # Function to set or update a variable in the generated.env # Usage: set_env_var [mask] # If mask is "true", the value will be masked in the output # This function will uncomment and update commented variables (e.g., #VAR=value) set_env_var() { local var_name="${1}" local var_value="${2}" local mask="${3:-false}" local display_value="${var_value}" if [[ "${mask}" == "true" ]]; then display_value="$(mask_secret "${var_value}")" fi if grep -q "^${var_name}=" "${_generated_env}"; then # Variable exists (uncommented), update it sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "${_generated_env}" elif grep -Eq "^#[[:space:]]*${var_name}=" "${_generated_env}"; then # Variable exists but is commented (with optional whitespace), uncomment and update it sed -i -E "s|^#[[:space:]]*${var_name}=.*|${var_name}=${var_value}|" "${_generated_env}" else # Variable doesn't exist, append it echo "${var_name}=${var_value}" >> "${_generated_env}" fi echo "[INFO] Set ${var_name}=${display_value}" } # Set the required environment variables set_env_var "MDX_SAMPLE_APPS_DIR" "${deployment_directory}" set_env_var "MDX_DATA_DIR" "${data_directory}" set_env_var "HOST_IP" "${host_ip}" if [[ -n "${external_ip}" ]]; then set_env_var "EXTERNAL_IP" "${external_ip}" fi # ===== Brev Secure Links ===== # On Brev, route all browser-facing traffic through the nginx reverse proxy # on a single port. This avoids CORS issues with Cloudflare Access when # each port gets its own hostname. if [[ -n "${BREV_ENV_ID:-}" ]]; then local _proxy_port="${PROXY_PORT:-7777}" local _brev_base="${BREV_ENV_ID}.brevlab.com" # Brev launchables create secure links with a "0" suffix on the port name # (e.g. port 7777 → "77770-xxx.brevlab.com"). Manual secure links don't add it. # Override BREV_LINK_PREFIX if the default doesn't match your setup. local _link_prefix="${BREV_LINK_PREFIX:-${_proxy_port}0}" local _proxy_https="https://${_link_prefix}-${_brev_base}" local _proxy_wss="wss://${_link_prefix}-${_brev_base}" echo "[INFO] Brev environment detected (${BREV_ENV_ID}). Routing through proxy at port ${_proxy_port}..." set_env_var "BREV_ENV_ID" "${BREV_ENV_ID}" set_env_var "PROXY_PORT" "${_proxy_port}" set_env_var "PROXY_MODE" "proxy" # All browser-facing URLs → single proxy origin (no cross-origin, no CORS) set_env_var "BREV_WS_AGENT_URL" "${_proxy_wss}/websocket" set_env_var "BREV_API_URL" "${_proxy_https}/api/v1" set_env_var "BREV_VST_API_URL" "${_proxy_https}/vst/api" set_env_var "BREV_MDX_URL" "${_proxy_https}" set_env_var "BREV_KIBANA_URL" "https://56010-${_brev_base}" # iframe — separate link OK set_env_var "KIBANA_PUBLIC_URL" "https://56010-${_brev_base}" set_env_var "BREV_MAP_URL" "${_proxy_https}" # Backend overrides set_env_var "VST_EXTERNAL_URL" "${_proxy_https}" set_env_var "VSS_AGENT_EXTERNAL_URL" "${_proxy_https}" set_env_var "VSS_AGENT_REPORTS_BASE_URL" "${_proxy_https}/static/" fi set_env_var "NGC_CLI_API_KEY" "${ngc_cli_api_key}" "true" set_env_var "HARDWARE_PROFILE" "${hardware_profile}" if [[ -n "${mode_env}" ]]; then set_env_var "MODE" "${mode_env}" fi # ===== LLM Configuration ===== # Derived LLM_MODE (remote when --use-remote-llm is passed; else local_shared or local from device IDs and FIXED_SHARED_DEVICE_IDS) set_env_var "LLM_MODE" "${llm_mode}" if [[ "${llm_mode}" == "remote" ]] && [[ -n "${llm_base_url}" ]]; then local _llm_name if [[ -n "${llm}" ]]; then _llm_name="${llm}" else _llm_name="$(get_remote_model_name "${llm_base_url}")" if [[ -z "${_llm_name}" ]]; then echo "[ERROR] Could not get LLM model name from ${llm_base_url}/v1/models. Pass --llm to override." exit 1 fi fi set_env_var "LLM_NAME" "${_llm_name}" set_env_var "LLM_NAME_SLUG" "none" elif [[ -n "${llm}" ]]; then set_env_var "LLM_NAME" "${llm}" set_env_var "LLM_NAME_SLUG" "$(get_llm_slug "${llm}")" fi if contains_element "${hardware_profile}" "${edge_hardware_profiles[@]}"; then set_env_var "LLM_DEVICE_ID" "0" set_env_var "VLM_DEVICE_ID" "0" else if [[ "${llm_mode}" != "remote" ]] && [[ -n "${llm_device_id}" ]]; then set_env_var "LLM_DEVICE_ID" "${llm_device_id}" fi if [[ "${vlm_mode}" != "remote" ]] && [[ -n "${vlm_device_id}" ]]; then set_env_var "VLM_DEVICE_ID" "${vlm_device_id}" fi fi if [[ -n "${llm_base_url}" ]]; then set_env_var "LLM_BASE_URL" "${llm_base_url}" fi if [[ "${llm_mode}" == "remote" ]]; then local _llm_type="${llm_model_type:-$(get_env_value "${_source_env}" "LLM_MODEL_TYPE")}" if [[ -n "${_llm_type}" ]]; then set_env_var "LLM_MODEL_TYPE" "${_llm_type}" fi fi if [[ -n "${nvidia_api_key}" ]]; then set_env_var "NVIDIA_API_KEY" "${nvidia_api_key}" "true" fi if [[ -n "${llm_env_file}" ]]; then set_env_var "LLM_ENV_FILE" "${llm_env_file}" fi # ===== VLM Configuration ===== # Derived VLM_MODE written to generated.env (remote when --use-remote-vlm is passed or profile=search; else local_shared or local from device IDs and FIXED_SHARED_DEVICE_IDS) if [[ "${profile}" == "search" ]]; then set_env_var "VLM_MODE" "remote" else set_env_var "VLM_MODE" "${vlm_mode}" fi if [[ "${vlm_mode}" == "remote" ]] && [[ -n "${vlm_base_url}" ]]; then local _vlm_name if [[ -n "${vlm}" ]]; then _vlm_name="${vlm}" else _vlm_name="$(get_remote_model_name "${vlm_base_url}")" if [[ -z "${_vlm_name}" ]]; then echo "[ERROR] Could not get VLM model name from ${vlm_base_url}/v1/models. Pass --vlm to override." exit 1 fi fi set_env_var "VLM_NAME" "${_vlm_name}" set_env_var "VLM_NAME_SLUG" "none" elif [[ -n "${vlm}" ]]; then set_env_var "VLM_NAME" "${vlm}" set_env_var "VLM_NAME_SLUG" "$(get_vlm_slug "${vlm}")" fi if [[ "${vlm_mode}" == "remote" ]]; then set_env_var "VLM_NAME_SLUG" "none" fi if [[ -n "${vlm_base_url}" ]]; then set_env_var "VLM_BASE_URL" "${vlm_base_url}" set_env_var "RTVI_VLM_ENDPOINT" "${vlm_base_url}/v1" set_env_var "RTVI_VLM_MODEL_PATH" "none" fi if [[ "${vlm_mode}" == "remote" ]]; then local _vlm_type="${vlm_model_type:-$(get_env_value "${_source_env}" "VLM_MODEL_TYPE")}" if [[ -n "${_vlm_type}" ]]; then set_env_var "VLM_MODEL_TYPE" "${_vlm_type}" fi fi if [[ -n "${openai_api_key}" ]]; then set_env_var "OPENAI_API_KEY" "${openai_api_key}" "true" fi # For local_shared/local VLM modes with 2d_vlm, configure rtvi-vlm to use the shared NIM endpoint if [[ "${profile}" == "alerts" ]] && [[ "${mode_env}" == "2d_vlm" ]] && [[ "${vlm_mode}" != "remote" ]]; then local _vlm_port _vlm_port="$(get_env_value "${_source_env}" "VLM_PORT")" _vlm_port="${_vlm_port:-30082}" set_env_var "RTVI_VLM_MODEL_PATH" "none" set_env_var "RTVI_VLM_ENDPOINT" "http://\${HOST_IP}:${_vlm_port}/v1" echo "[INFO] Configured rtvi-vlm to use shared NIM endpoint on port ${_vlm_port}" fi # Handle custom weights for VLM # Skip if: profile=search (no VLM needed) or vlm_mode=remote (VLM hosted remotely) if [[ "${profile}" == "search" ]]; then echo "[INFO] Skipping VLM custom weights - not required for search profile" elif [[ "${vlm_mode}" == "remote" ]]; then echo "[INFO] Skipping VLM custom weights - not required when --use-remote-vlm is passed" elif [[ -n "${vlm_custom_weights}" ]]; then echo "[INFO] Using VLM custom weights path: ${vlm_custom_weights}" set_env_var "VLM_CUSTOM_WEIGHTS" "${vlm_custom_weights}" fi if [[ -n "${vlm_env_file}" ]]; then set_env_var "VLM_ENV_FILE" "${vlm_env_file}" fi # Alerts profile: conditionally set perception prefix for edge (DGX-SPARK, IGX-THOR, AGX-THOR) if [[ "${profile}" == "alerts" ]] && contains_element "${hardware_profile}" "${edge_hardware_profiles[@]}"; then set_env_var "PERCEPTION_DOCKERFILE_PREFIX" "EDGE-" fi # Alerts profile: conditionally set vlm-as-verifier config prefix for IGX-THOR, AGX-THOR only; DGX-SPARK uses default config.yml if [[ "${profile}" == "alerts" ]] && ([[ "${hardware_profile}" == "IGX-THOR" ]] || [[ "${hardware_profile}" == "AGX-THOR" ]]) && [[ "${vlm_mode}" != "remote" ]]; then set_env_var "VLM_AS_VERIFIER_CONFIG_FILE_PREFIX" "EDGE-LOCAL-VLM-" fi # DGX-SPARK, IGX-THOR, AGX-THOR with alerts profile only: set RTVI VLM input dimensions and frame rate (not set on any other platform or profile) if [[ "${profile}" == "alerts" ]] && contains_element "${hardware_profile}" "${edge_hardware_profiles[@]}"; then set_env_var "RTVI_VLM_INPUT_WIDTH" "860" set_env_var "RTVI_VLM_INPUT_HEIGHT" "467" set_env_var "RTVI_VLM_DEFAULT_NUM_FRAMES_PER_SECOND_OR_FIXED_FRAMES_CHUNK" "20" fi # Alerts or base profile on IGX-THOR or AGX-THOR: set VLM name/slug, base URL, and RTVI-related env (fixed configuration) if ([[ "${hardware_profile}" == "IGX-THOR" ]] || [[ "${hardware_profile}" == "AGX-THOR" ]]) && ([[ "${profile}" == "alerts" ]] || [[ "${profile}" == "base" ]]); then set_env_var "VLM_NAME_SLUG" "none" set_env_var "VLM_NAME" "nim_nvidia_cosmos-reason2-8b_hf-1208" set_env_var "VLM_BASE_URL" "http://${host_ip}:8018" set_env_var "RTVI_VLM_MODEL_PATH" "ngc:nim/nvidia/cosmos-reason2-8b:hf-1208" set_env_var "RTVI_VLM_MODEL_TO_USE" "cosmos-reason2" set_env_var "RTVI_VLLM_GPU_MEMORY_UTILIZATION" "0.35" fi # Base profile only on IGX-THOR or AGX-THOR: set VLM_MODEL_TYPE to rtvi (alerts does not use rtvi) if ([[ "${hardware_profile}" == "IGX-THOR" ]] || [[ "${hardware_profile}" == "AGX-THOR" ]]) && [[ "${profile}" == "base" ]]; then set_env_var "VLM_MODEL_TYPE" "rtvi" fi # When hardware profile is DGX-SPARK: for any env var that has a commented line with sbsa in the value, # comment the uncommented line (non-sbsa) and uncomment the sbsa line. Discover keys from the file. # Comment format may be "# VAR=..." or "#VAR=..." (optional space after #). if [[ "${hardware_profile}" == "DGX-SPARK" ]]; then local _key while IFS= read -r _key; do [[ -z "${_key}" ]] && continue # Comment the uncommented line for this key when value does not contain sbsa sed -i -E "/sbsa/! s/^(${_key})=(.*)/# \1=\2/" "${_generated_env}" # Uncomment the commented line for this key when value contains sbsa sed -i -E "/sbsa/ s/^#[[:space:]]*(${_key})=(.*)/\1=\2/" "${_generated_env}" echo "[INFO] Swapped to SBSA (DGX-SPARK): ${_key}" done < <(grep -E '^#[[:space:]]*[A-Za-z0-9_]+=.*sbsa' "${_generated_env}" 2>/dev/null | sed -nE 's/^#[[:space:]]*([A-Za-z0-9_]+)=.*/\1/p' | sort -u) fi echo "[INFO] Generated environment file: ${_generated_env}" # Create required directories echo "[INFO] Creating data directories..." mkdir -p "${data_directory}/data_log/analytics_cache" mkdir -p "${data_directory}/data_log/calibration_toolkit" mkdir -p "${data_directory}/data_log/elastic/data" mkdir -p "${data_directory}/data_log/elastic/logs" mkdir -p "${data_directory}/data_log/kafka" mkdir -p "${data_directory}/data_log/redis/data" mkdir -p "${data_directory}/data_log/redis/log" mkdir -p "${data_directory}/agent_eval/dataset/" mkdir -p "${data_directory}/agent_eval/results/" # Create alerts-specific directories and download models if [[ "${profile}" == "alerts" ]]; then echo "[INFO] Creating alerts-specific directories..." mkdir -p "${data_directory}/data_log/vss_video_analytics_api" mkdir -p "${data_directory}/videos/dev-profile-alerts" # Download alerts models from NGC echo "[INFO] Downloading alerts models from NGC..." if [[ "${dry_run}" == "true" ]]; then echo "[DRY-RUN] rm -rf ${data_directory}/models" echo "[DRY-RUN] mkdir -p ${data_directory}/models/rtdetr-its" echo "[DRY-RUN] mkdir -p ${data_directory}/models/gdino" echo "[DRY-RUN] NGC_CLI_API_KEY= ngc registry model download-version nvidia/tao/trafficcamnet_transformer_lite:deployable_resnet50_v2.0" echo "[DRY-RUN] mv trafficcamnet_transformer_lite_vdeployable_resnet50_v2.0/resnet50_trafficcamnet_rtdetr.fp16.onnx ${data_directory}/models/rtdetr-its/model_epoch_035.fp16.onnx" echo "[DRY-RUN] rm -rf trafficcamnet_transformer_lite_vdeployable_resnet50_v2.0" echo "[DRY-RUN] NGC_CLI_API_KEY= ngc registry model download-version nvidia/tao/mask_grounding_dino:mask_grounding_dino_swin_tiny_commercial_deployable_v2.1_wo_mask_arm" echo "[DRY-RUN] mv mask_grounding_dino_vmask_grounding_dino_swin_tiny_commercial_deployable_v2.1_wo_mask_arm/mgdino_mask_head_pruned_dynamic_batch.onnx ${data_directory}/models/gdino/mgdino_mask_head_pruned_dynamic_batch.onnx" echo "[DRY-RUN] rm -rf mask_grounding_dino_vmask_grounding_dino_swin_tiny_commercial_deployable_v2.1_wo_mask_arm" echo "[DRY-RUN] chmod -R 777 ${data_directory}/models" else rm -rf "${data_directory}/models" mkdir -p "${data_directory}/models/rtdetr-its" mkdir -p "${data_directory}/models/gdino" # Download and install trafficcamnet RT-DETR model NGC_CLI_API_KEY="${ngc_cli_api_key}" ngc \ registry \ model \ download-version \ nvidia/tao/trafficcamnet_transformer_lite:deployable_resnet50_v2.0 mv trafficcamnet_transformer_lite_vdeployable_resnet50_v2.0/resnet50_trafficcamnet_rtdetr.fp16.onnx \ "${data_directory}/models/rtdetr-its/model_epoch_035.fp16.onnx" rm -rf trafficcamnet_transformer_lite_vdeployable_resnet50_v2.0 # Download and install grounding DINO model NGC_CLI_API_KEY="${ngc_cli_api_key}" ngc \ registry \ model \ download-version \ nvidia/tao/mask_grounding_dino:mask_grounding_dino_swin_tiny_commercial_deployable_v2.1_wo_mask_arm mv mask_grounding_dino_vmask_grounding_dino_swin_tiny_commercial_deployable_v2.1_wo_mask_arm/mgdino_mask_head_pruned_dynamic_batch.onnx \ "${data_directory}/models/gdino/mgdino_mask_head_pruned_dynamic_batch.onnx" rm -rf mask_grounding_dino_vmask_grounding_dino_swin_tiny_commercial_deployable_v2.1_wo_mask_arm chmod -R 777 "${data_directory}/models" echo "[INFO] Alerts models downloaded and installed to ${data_directory}/models" fi fi if [[ "${profile}" == "search" ]]; then # Download search models from NGC echo "[INFO] Downloading models from NGC..." if [[ "${dry_run}" == "true" ]]; then echo "[DRY-RUN] rm -rf ${data_directory}/models" echo "[DRY-RUN] mkdir -p ${data_directory}/models" echo "[DRY-RUN] NGC_CLI_API_KEY= ngc registry model download-version nvidia/tao/rtdetr_2d_warehouse:deployable_efficientvit_l2_v1.0.1" echo "[DRY-RUN] NGC_CLI_API_KEY= ngc registry model download-version nvidia/tao/radio-clip:deployable_v1.0" echo "[DRY-RUN] mv rtdetr_2d_warehouse_vdeployable_efficientvit_l2_v1.0.1/rtdetr_warehouse_v1.0.1.fp16.onnx ${data_directory}/models/rtdetr_warehouse_v1.0.1.fp16.onnx" echo "[DRY-RUN] mv radio-clip_vdeployable_v1.0/radio-clip_v1.0.onnx ${data_directory}/models/radio-clip_v1.0.onnx" echo "[DRY-RUN] mv radio-clip_vdeployable_v1.0/radio-clip_v1.0_weights.bin ${data_directory}/models/radio-clip_v1.0_weights.bin" echo "[DRY-RUN] mv radio-clip_vdeployable_v1.0/radio-clip_v1.0_tokenizer ${data_directory}/models/radio-clip_v1.0_tokenizer" echo "[DRY-RUN] rm -rf rtdetr_2d_warehouse_vdeployable_efficientvit_l2_v1.0.1" echo "[DRY-RUN] rm -rf radio-clip_vdeployable_v1.0" echo "[DRY-RUN] chmod -R 777 ${data_directory}/models" else rm -rf "${data_directory}/models" mkdir -p "${data_directory}/models" # Download and install RT-DETR warehouse model (TAO) NGC_CLI_API_KEY="${ngc_cli_api_key}" ngc \ registry \ model \ download-version \ nvidia/tao/rtdetr_2d_warehouse:deployable_efficientvit_l2_v1.0.1 NGC_CLI_API_KEY="${ngc_cli_api_key}" ngc \ registry \ model \ download-version \ nvidia/tao/radio-clip:deployable_v1.0 mv rtdetr_2d_warehouse_vdeployable_efficientvit_l2_v1.0.1/rtdetr_warehouse_v1.0.1.fp16.onnx "${data_directory}/models/rtdetr_warehouse_v1.0.1.fp16.onnx" mv radio-clip_vdeployable_v1.0/radio-clip_v1.0.onnx "${data_directory}/models/radio-clip_v1.0.onnx" mv radio-clip_vdeployable_v1.0/radio-clip_v1.0_weights.bin "${data_directory}/models/radio-clip_v1.0_weights.bin" mv radio-clip_vdeployable_v1.0/radio-clip_v1.0_tokenizer "${data_directory}/models/radio-clip_v1.0_tokenizer" rm -rf rtdetr_2d_warehouse_vdeployable_efficientvit_l2_v1.0.1 rm -rf radio-clip_vdeployable_v1.0 chmod -R 777 "${data_directory}/models" echo "[INFO] Search models downloaded and installed to ${data_directory}/models" fi fi # Set permissions on data_log directory echo "[INFO] Setting permissions on data_log directory..." chmod -R 777 "${data_directory}/data_log" # Set permissions on agent_eval directory echo "[INFO] Setting permissions on agent_eval directory..." chmod -R 777 "${data_directory}/agent_eval" # VSS kernel settings (non-dry-run only) if [[ "${dry_run}" != "true" ]]; then echo "[INFO] Applying VSS Linux kernel settings..." set_vss_linux_kernel_settings fi # Docker login to nvcr.io echo "[INFO] Logging into nvcr.io..." if [[ "${dry_run}" == "true" ]]; then echo "[DRY-RUN] docker login --username '\$oauthtoken' --password nvcr.io" else docker login \ --username '$oauthtoken' \ --password "${ngc_cli_api_key}" \ nvcr.io fi # Docker compose up echo "[INFO] Starting docker compose..." if [[ "${dry_run}" == "true" ]]; then echo "[DRY-RUN] cd ${deployment_directory} && docker compose --env-file developer-workflow/dev-profile-${profile}/generated.env up --detach --force-recreate --build" else cd "${deployment_directory}" && docker compose \ --env-file "developer-workflow/dev-profile-${profile}/generated.env" \ up \ --detach \ --force-recreate \ --build fi echo "[INFO] State up completed" } function state_down() { local _profile_dir_names _profile_dir_name _generated_env echo "[INFO] Cleaning up generated.env files from all profiles..." _profile_dir_names=('base' 'lvs' 'search' 'alerts') for _profile_dir_name in "${_profile_dir_names[@]}"; do _generated_env="${deployment_directory}/developer-workflow/dev-profile-${_profile_dir_name}/generated.env" if [[ -f "${_generated_env}" ]]; then if [[ "${dry_run}" == "true" ]]; then echo "[DRY-RUN] rm -f ${_generated_env}" else rm -f "${_generated_env}" echo "[INFO] Deleted ${_generated_env}" fi fi done echo "[INFO] Bringing down docker compose project 'mdx'..." if [[ "${dry_run}" == "true" ]]; then echo "[DRY-RUN] docker compose -p mdx down" else docker compose -p mdx down fi echo "[INFO] Removing dangling docker volumes..." if [[ "${dry_run}" == "true" ]]; then echo "[DRY-RUN] docker volume ls -q -f \"dangling=true\" | xargs docker volume rm" else dangling_volumes=$(docker volume ls -q -f "dangling=true") if [[ -n "${dangling_volumes}" ]]; then echo "${dangling_volumes}" | xargs docker volume rm else echo "[INFO] No dangling volumes to remove" fi fi echo "[INFO] Deleting data directory: ${data_directory}..." if [[ "${dry_run}" == "true" ]]; then echo "[DRY-RUN] sudo rm -rf ${data_directory}" else if [[ -d "${data_directory}" ]]; then sudo rm -rf "${data_directory}" echo "[INFO] Data directory deleted" else echo "[INFO] Data directory does not exist, skipping" fi fi echo "[INFO] State down completed" } # Main execution validate_args "${@}" process_args "${@}" print_args if [[ "${desired_state}" == "up" ]]; then state_down state_up elif [[ "${desired_state}" == "down" ]]; then state_down fi ================================================ FILE: ui/.dockerignore ================================================ Dockerfile **/.gitignore .git **/*.md .idea **/.turbo/ **/.turbo/** .vscode .vscode/** **/*.env* .env* **/.env* **/node_modules/ **/node_modules/** .github/ .github/** # Build artifacts **/lib/ **/lib/** **/dist/ **/dist/** **/build/ **/build/** **/*.tsbuildinfo **/.next/ **/.next/** **/out/ **/out/** **/.swc/ **/.swc/** **/coverage/ **/coverage/** # OS files **/.DS_Store **/Thumbs.db # Editor files **/*.swp **/*.swo **/*~ # Test files **/*.test.js **/*.test.ts **/*.test.tsx **/*.spec.js **/*.spec.ts **/*.spec.tsx ================================================ FILE: ui/.eslintrc.js ================================================ module.exports = require("./packages/nemo-agent-toolkit-ui/.eslintrc.js"); ================================================ FILE: ui/.gitignore ================================================ .venv/ node_modules/ .turbo/ .idea/ # Build outputs dist/ build/ lib/ *.tsbuildinfo # Compiled files *.js.map *.d.ts.map # Environment files .env* # OS files .DS_Store Thumbs.db # Editor files .vscode/ *.swp *.swo *~ # Testing coverage/ .nyc_output/ # Next.js .next/ out/ # SWC .swc/ ================================================ FILE: ui/CODE-OF-CONDUCT.md ================================================ This project has adopted the [Contributor Covenant Code of Conduct](https://docs.rapids.ai/resources/conduct/). ================================================ FILE: ui/CONTRIBUTING.md ================================================ # Contributing Guidelines **Welcome to NeMo Agent Toolkit UI** We appreciate your interest in contributing to our project. Before you get started, please read our guidelines for contributing. ## Types of Contributions We welcome the following types of contributions: - Bug fixes - New features - Documentation improvements - Code optimizations - Translations - Tests ## Get Started To get started, fork the project on GitHub and clone it locally on your machine. Then, create a new branch to work on your changes. ```bash git clone git@github.com:NVIDIA/NeMo-Agent-Toolkit-UI.git cd NeMo-Agent-Toolkit-UI git checkout -b my-branch-name ``` Before submitting your pull request, please make sure your changes pass our automated tests and adhere to our code style guidelines. ## Pull Request Process 1. Fork the project on GitHub. 2. Clone your forked repository locally on your machine. 3. Create a new branch from the main branch. 4. Make your changes on the new branch. 5. Ensure that your changes adhere to our code style guidelines and pass our automated tests. 6. Commit your changes and push them to your forked repository. 7. Submit a pull request to the main branch of the main repository. ================================================ FILE: ui/DOCKER-README.md ================================================ # Docker Readme Uses standalone production build of the app. Uses custom-server.js to start the server. .env sample to use for docker run when running the Metropolis BP VSS UI app: ``` PORT=3001 RUN_APP_NAME=nv-metropolis-bp-vss-ui NEXT_PUBLIC_APP_TITLE=VSS BLUEPRINT NEXT_PUBLIC_APP_SUBTITLE=Warehouse NEXT_PUBLIC_ENABLE_CHAT_TAB=true NEXT_PUBLIC_WORKFLOW=Warehouse Management Agent NEXT_PUBLIC_WEBSOCKET_CHAT_COMPLETION_URL=ws://127.0.0.1:8000/websocket NEXT_PUBLIC_HTTP_CHAT_COMPLETION_URL=http://127.0.0.1:8000/chat/stream NEXT_PUBLIC_WEB_SOCKET_DEFAULT_ON=false NEXT_PUBLIC_CHAT_HISTORY_DEFAULT_ON=true NEXT_PUBLIC_RIGHT_MENU_OPEN=false NEXT_PUBLIC_ENABLE_INTERMEDIATE_STEPS=true NEXT_PUBLIC_DARK_THEME_DEFAULT=true NEXT_PUBLIC_SHOW_THEME_TOGGLE_BUTTON=true NEXT_PUBLIC_SIDE_CHATBAR_COLLAPSED=true NEXT_PUBLIC_AGENT_API_URL_BASE=http://127.0.0.1:8000/api/v1 NEXT_PUBLIC_CHAT_UPLOAD_FILE_ENABLE=true NEXT_PUBLIC_CHAT_INPUT_MIC_ENABLED=false NEXT_PUBLIC_INTERACTION_MODAL_CANCEL_ENABLED=false NEXT_PUBLIC_CHAT_MESSAGE_EDIT_ENABLED=true NEXT_PUBLIC_CHAT_MESSAGE_SPEAKER_ENABLED=true NEXT_PUBLIC_CHAT_MESSAGE_COPY_ENABLED=true NEXT_PUBLIC_CHAT_UPLOAD_FILE_METADATA_ENABLED=false NEXT_PUBLIC_CHAT_UPLOAD_FILE_HIDDEN_MESSAGE_TEMPLATE=Can you show the video clip of the video {filenames} that I just uploaded? # Upload file config template JSON - Configure form fields for file upload # Format: {"fields": [, , ...]} # Each field object: # - field-name: string - Name of the field (e.g., "embedding", "description") # - field-type: "boolean" | "string" | "number" | "select" - Input type # - field-default-value: any - Default value for the field # - field-options: string[] - Options for select type (e.g., ["Type 1", "Type 2"]) # - changeable: boolean - Allow user to modify value (true=editable, false=readonly) # - tooltip-info: string - Tooltip text on hover NEXT_PUBLIC_CHAT_UPLOAD_FILE_CONFIG_TEMPLATE_JSON='{ "fields": [ { "field-name": "embedding", "field-type": "boolean", "field-default-value": true, "changeable": false, "tooltip-info": "" } ] }' # Custom Agent Parameters JSON - Configure dynamic form fields for chat request # Format: {"params": [, , ...]} # Each param object: # - name: string - Parameter key sent to backend API (e.g., "llm_reasoning", "model") # - label: string - Display label shown in the form UI # - type: "boolean" | "string" | "number" | "select" - Input type # - default-value: any - Initial value for the parameter # - options: string[] - Options for select type (e.g., ["gpt-4", "gpt-3.5-turbo"]) # - changeable: boolean - Allow user to modify value (true=editable, false=readonly) # - tooltip-info: string - Tooltip text on hover NEXT_PUBLIC_CHAT_API_CUSTOM_AGENT_PARAMS_JSON='{ "params": [ { "name": "llm_reasoning", "label": "LLM Reasoning", "type": "boolean", "default-value": false, "changeable": true, "tooltip-info": "" }, { "name": "vlm_reasoning", "label": "VLM Reasoning", "type": "boolean", "default-value": false, "changeable": true, "tooltip-info": "" } ] }' NEXT_PUBLIC_ENABLE_ALERTS_TAB=true NEXT_PUBLIC_VST_API_URL=http://127.0.0.1:30888/vst/api NEXT_PUBLIC_MDX_WEB_API_URL=http://127.0.0.1:8081 NEXT_PUBLIC_ALERTS_TAB_MAX_RESULT_SIZE=100 NEXT_PUBLIC_ALERTS_TAB_DEFAULT_TIME_WINDOW_IN_MINUTES=10 NEXT_PUBLIC_ALERTS_TAB_DEFAULT_AUTO_REFRESH_IN_MILLISECONDS=1000 NEXT_PUBLIC_ALERTS_TAB_VERIFIED_FLAG_DEFAULT=true NEXT_PUBLIC_ALERTS_TAB_ALERT_REPORT_PROMPT_TEMPLATE=Generate a report for incident '{incidentId}' with sensor id {sensorId}. # Max search time limit (0 = unlimited, or use: 10m, 2h, 3d, 1w, 2M, 1y) NEXT_PUBLIC_ALERTS_TAB_MAX_SEARCH_TIME_LIMIT=0 NEXT_PUBLIC_ALERTS_TAB_MEDIA_WITH_OBJECTS_BBOX=true # Default false; set to true to enable Chat sidebar on Alerts tab NEXT_PUBLIC_ALERTS_TAB_CHAT_SIDEBAR_ENABLE=false NEXT_PUBLIC_SEARCH_TAB_MEDIA_WITH_OBJECTS_BBOX=true NEXT_PUBLIC_ENABLE_SEARCH_TAB=true # --- Search Tab Chat (collapsible sidebar) --- # Default false; set to true to enable Chat sidebar on Search tab NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDEBAR_ENABLE=false # Default state when opening Search tab: true = sidebar open, false = sidebar collapsed NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDEBAR_OPEN_DEFAULT=false # Same semantics as main Chat tab; prefix NEXT_PUBLIC_SEARCH_TAB_CHAT_* (fallback to main NEXT_PUBLIC_* if unset) NEXT_PUBLIC_SEARCH_TAB_CHAT_WORKFLOW=Search Agent NEXT_PUBLIC_SEARCH_TAB_CHAT_WEBSOCKET_CHAT_COMPLETION_URL=ws://127.0.0.1:8000/websocket NEXT_PUBLIC_SEARCH_TAB_CHAT_HTTP_CHAT_COMPLETION_URL=http://127.0.0.1:8000/chat/stream NEXT_PUBLIC_SEARCH_TAB_CHAT_WEB_SOCKET_DEFAULT_ON=false NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_HISTORY_DEFAULT_ON=true NEXT_PUBLIC_SEARCH_TAB_CHAT_ENABLE_INTERMEDIATE_STEPS=true NEXT_PUBLIC_SEARCH_TAB_CHAT_DARK_THEME_DEFAULT=true NEXT_PUBLIC_SEARCH_TAB_CHAT_SIDE_CHATBAR_COLLAPSED=true NEXT_PUBLIC_SEARCH_TAB_CHAT_AGENT_API_URL_BASE=http://127.0.0.1:8000/api/v1 NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_UPLOAD_FILE_ENABLE=true NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_INPUT_MIC_ENABLED=false NEXT_PUBLIC_SEARCH_TAB_CHAT_INTERACTION_MODAL_CANCEL_ENABLED=false NEXT_PUBLIC_SEARCH_TAB_CHAT_CHAT_UPLOAD_FILE_METADATA_ENABLED=false NEXT_PUBLIC_SEARCH_TAB_CHAT_SHOW_THEME_TOGGLE_BUTTON=false NEXT_PUBLIC_ENABLE_DASHBOARD_TAB=true NEXT_PUBLIC_DASHBOARD_TAB_KIBANA_BASE_URL=http://127.0.0.1:5601 NEXT_PUBLIC_ENABLE_MAP_TAB=true NEXT_PUBLIC_MAP_URL=http://127.0.0.1:3002 NEXT_PUBLIC_ENABLE_VIDEO_MANAGEMENT_TAB=true # Add RTSP button in Video Management tab (enabled by default, set to 'false' to hide) NEXT_PUBLIC_VIDEO_MANAGEMENT_TAB_ADD_RTSP_ENABLE=true # Upload Video button in Video Management tab (enabled by default, set to 'false' to hide) NEXT_PUBLIC_VIDEO_MANAGEMENT_VIDEO_UPLOAD_ENABLE=true ``` .env sample to use for docker run when running the NeMo Agent Toolkit UI app: ``` PORT=3000 RUN_APP_NAME=nemo-agent-toolkit-ui NEXT_PUBLIC_WORKFLOW=NeMo Agent Toolkit NEXT_PUBLIC_WEBSOCKET_CHAT_COMPLETION_URL=ws://127.0.0.1:8000/websocket NEXT_PUBLIC_HTTP_CHAT_COMPLETION_URL=http://127.0.0.1:8000/chat/stream NEXT_PUBLIC_WEB_SOCKET_DEFAULT_ON=false NEXT_PUBLIC_CHAT_HISTORY_DEFAULT_ON=false NEXT_PUBLIC_RIGHT_MENU_OPEN=false NEXT_PUBLIC_ENABLE_INTERMEDIATE_STEPS=true NEXT_PUBLIC_DARK_THEME_DEFAULT=false NEXT_PUBLIC_SIDE_CHATBAR_COLLAPSED=false NEXT_PUBLIC_AGENT_API_URL_BASE=http://127.0.0.1:8000/api/v1 NEXT_PUBLIC_CHAT_UPLOAD_FILE_ENABLE=false NEXT_PUBLIC_CHAT_INPUT_MIC_ENABLED=true NEXT_PUBLIC_INTERACTION_MODAL_CANCEL_ENABLED=true NEXT_PUBLIC_CHAT_MESSAGE_EDIT_ENABLED=true NEXT_PUBLIC_CHAT_MESSAGE_SPEAKER_ENABLED=true NEXT_PUBLIC_CHAT_MESSAGE_COPY_ENABLED=true NEXT_PUBLIC_CHAT_UPLOAD_FILE_METADATA_ENABLED=false NEXT_PUBLIC_CHAT_UPLOAD_FILE_HIDDEN_MESSAGE_TEMPLATE=Can you show the video clip of the video {filenames} that I just uploaded? # Upload file config template JSON - Configure form fields for file upload # Format: {"fields": [, , ...]} # Each field object: # - field-name: string - Name of the field (e.g., "embedding", "description") # - field-type: "boolean" | "string" | "number" | "select" - Input type # - field-default-value: any - Default value for the field # - field-options: string[] - Options for select type (e.g., ["Type 1", "Type 2"]) # - changeable: boolean - Allow user to modify value (true=editable, false=readonly) # - tooltip-info: string - Tooltip text on hover NEXT_PUBLIC_CHAT_UPLOAD_FILE_CONFIG_TEMPLATE_JSON='{ "fields": [ { "field-name": "embedding", "field-type": "boolean", "field-default-value": true, "changeable": false, "tooltip-info": "" } ] }' # Custom Agent Parameters JSON - Configure dynamic form fields for chat request # Format: {"params": [, , ...]} # Each param object: # - name: string - Parameter key sent to backend API (e.g., "llm_reasoning", "model") # - label: string - Display label shown in the form UI # - type: "boolean" | "string" | "number" | "select" - Input type # - default-value: any - Initial value for the parameter # - options: string[] - Options for select type (e.g., ["gpt-4", "gpt-3.5-turbo"]) # - changeable: boolean - Allow user to modify value (true=editable, false=readonly) # - tooltip-info: string - Tooltip text on hover NEXT_PUBLIC_CHAT_API_CUSTOM_AGENT_PARAMS_JSON='{ "params": [ { "name": "llm_reasoning", "label": "LLM Reasoning", "type": "boolean", "default-value": false, "changeable": true, "tooltip-info": "" }, { "name": "vlm_reasoning", "label": "VLM Reasoning", "type": "boolean", "default-value": false, "changeable": true, "tooltip-info": "" } ] }' ``` **Note:** RUN_APP_NAME should match the name of the app in the apps folder. Default is 'nemo-agent-toolkit-ui'. ```bash # Build the Docker image from the parent directory docker build -t -f Dockerfile . # OR # docker build -t --build-arg BUILD_TYPE=prod -f Dockerfile . # Run the container with environment variables from .env # Ensure the .env file is present before running this command. # Skip --env-file .env if no overrides are needed. # For metropolis-spatial-ai deployment overrides refer to above .env sample section docker run --env-file -p 3000:3000 # OR pass environment variables as arguments # docker run -e NEXT_PUBLIC_WORKFLOW="Agent" -p 3000:3000 ``` ## Debug inside the container Create a debug container image: ``` docker build -t --build-arg BUILD_TYPE=dev -f Dockerfile . ``` Since the resulting docker is a distroless docker image, if needed to run any commands to debug the container, you can use the following command: ``` docker run --entrypoint=sh --rm -it --env-file -p 3000:3000 ``` To start the app inside the debug container: ``` node custom-server.js ``` ================================================ FILE: ui/Dockerfile ================================================ ARG USER_ID=65532 ARG GROUP_ID=65532 ARG BUILD_TYPE=prod # Builder phase FROM node:22 AS builder ARG USER_ID ARG GROUP_ID WORKDIR /repo # Copy source files COPY ui/ ui/ ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_BUILD_TRACES false WORKDIR /repo/ui RUN npm install RUN npx turbo build # Set permissions for __ENV.js files to be writable at runtime RUN chmod -R 755 apps/nv-metropolis-bp-vss-ui/public && \ chmod -R 755 apps/nemo-agent-toolkit-ui/public && \ chown -R ${USER_ID}:${GROUP_ID} apps/nv-metropolis-bp-vss-ui/public && \ chown -R ${USER_ID}:${GROUP_ID} apps/nemo-agent-toolkit-ui/public # Conditional runner phase FROM nvcr.io/nvidia/distroless/node:22-v3.1.3 as runner-prod ARG BUILD_TYPE FROM nvcr.io/nvidia/distroless/node:22-v3.1.3-dev as runner-dev ARG BUILD_TYPE FROM runner-${BUILD_TYPE} as runner ARG USER_ID ARG GROUP_ID WORKDIR /repo # Copy standalone output from each app into its own subdirectory (read-only) COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/apps/nv-metropolis-bp-vss-ui/.next/standalone ./apps/nv-metropolis-bp-vss-ui/ COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/apps/nv-metropolis-bp-vss-ui/.next/static ./apps/nv-metropolis-bp-vss-ui/apps/nv-metropolis-bp-vss-ui/.next/static COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=755 /repo/ui/apps/nv-metropolis-bp-vss-ui/public ./apps/nv-metropolis-bp-vss-ui/apps/nv-metropolis-bp-vss-ui/public COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/apps/nemo-agent-toolkit-ui/.next/standalone ./apps/nemo-agent-toolkit-ui/ COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/apps/nemo-agent-toolkit-ui/.next/static ./apps/nemo-agent-toolkit-ui/apps/nemo-agent-toolkit-ui/.next/static COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=755 /repo/ui/apps/nemo-agent-toolkit-ui/public ./apps/nemo-agent-toolkit-ui/apps/nemo-agent-toolkit-ui/public # Copy required configuration files with their dependencies for next-runtime-env in custom-server.js (read-only) COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/node_modules/next-runtime-env ./node_modules/next-runtime-env COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/node_modules/chalk ./node_modules/chalk COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/node_modules/ansi-styles ./node_modules/ansi-styles COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/node_modules/supports-color ./node_modules/supports-color COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/node_modules/color-convert ./node_modules/color-convert COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/node_modules/color-name ./node_modules/color-name COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/node_modules/has-flag ./node_modules/has-flag # Copy custom server (read-only) COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=555 /repo/ui/custom-server.js ./custom-server.js # Copy license files (read-only) COPY --from=builder --chown=${USER_ID}:${GROUP_ID} --chmod=444 /repo/ui/LICENSE* ./ # Set environment variables ENV NODE_ENV production ENV NEXT_TELEMETRY_DISABLED 1 ENV PORT 3000 ENV HOST 0.0.0.0 # Create separate stages for prod and dev commands FROM runner as runner-prod-cmd ARG USER_ID ARG GROUP_ID # Ensure we run as nonroot user USER ${USER_ID}:${GROUP_ID} CMD ["node", "custom-server.js"] FROM runner as runner-dev-cmd ARG USER_ID ARG GROUP_ID USER ${USER_ID}:${GROUP_ID} # Final stage that selects the appropriate command based on BUILD_TYPE FROM runner-${BUILD_TYPE}-cmd ARG USER_ID ARG GROUP_ID USER ${USER_ID}:${GROUP_ID} ================================================ FILE: ui/LICENSE ================================================ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: MIT # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), # to deal in the Software without restriction, including without limitation # the rights to use, copy, modify, merge, publish, distribute, sublicense, # and/or sell copies of the Software, and to permit persons to whom the # Software is furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. /* * SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: MIT * * Permission is hereby granted, free of charge, to any person obtaining a * copy of this software and associated documentation files (the "Software"), * to deal in the Software without restriction, including without limitation * the rights to use, copy, modify, merge, publish, distribute, sublicense, * and/or sell copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER * DEALINGS IN THE SOFTWARE. */ MIT License Copyright (c) 2024 Ivan Fioravanti Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. MIT License Copyright (c) 2024 Mckay Wrigley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: ui/LICENSE-3rd-party.txt ================================================ NeMo Agent Toolkit UI Third-Party Licenses ================================= This file contains third-party license information for software packages used in this project. The licenses below apply to one or more packages included in this project. For each license, we list the packages that are distributed under it and include the full license text. ------------------------------------------------------------ MIT License ------------------------------------------------------------ The MIT License is a permissive free software license. Many of the packages used in this project are distributed under the MIT License. The full text of the MIT License is provided below. Packages under the MIT License: - @datadog/browser-rum @ ^5.11.0 - @dqbd/tiktoken @ ^1.0.2 - @radix-ui/react-select @ ^2.1.2 - @tabler/icons-react @ ^2.9.0 - chart.js @ ^4.4.1 - eventsource-parser @ ^0.1.0 - file-saver @ ^2.0.5 - form-data @ ^4.0.4 - html-to-image @ ^1.11.11 - http-proxy @ ^1.18.1 - i18next @ ^22.4.13 - jsonwebtoken @ ^9.0.2 - jwt-decode @ ^4.0.0 - lodash @ ^4.17.21 - lucide-react @ ^0.454.0 - next @ ^15.0.8 - next-auth @ ^4.24.13 - next-i18next @ ^13.2.2 - next-runtime-env @ ^1.3.0 - react @ ^18.2.0 - react-bootstrap-modal @ ^4.2.0 - react-chartjs-2 @ ^5.2.0 - react-dom @ ^18.2.0 - react-force-graph-2d @ ^1.25.5 - react-hot-toast @ ^2.4.0 - react-i18next @ ^12.2.0 - react-markdown @ ^10.1.0 - react-query @ ^3.39.3 - react-syntax-highlighter @ ^16.1.0 - recharts @ ^2.12.7 - rehype-mathjax @ ^7.1.0 - rehype-raw @ ^7.0.0 - remark-gfm @ ^4.0.1 - remark-math @ ^6.0.0 - uuid @ ^10.0.0 Dev Dependencies under the MIT License: - @mozilla/readability @ ^0.6.0 - @swc/cli @ ^0.8.0 - @swc/core @ ^1.13.19 - @tailwindcss/typography @ ^0.5.9 - @testing-library/jest-dom @ ^6.1.4 - @testing-library/react @ ^16.3.2 - @testing-library/user-event @ ^14.5.1 - @trivago/prettier-plugin-sort-imports @ ^1.4.4 - @types/http-proxy @ ^1.17.14 - @types/jsdom @ ^21.1.1 - @types/node @ 18.15.0 - @types/react @ 18.0.28 - @types/react-dom @ 18.0.11 - @types/react-syntax-highlighter @ ^15.5.6 - @types/uuid @ ^10.0.0 - @typescript-eslint/eslint-plugin @ ^8.52.0 - @typescript-eslint/parser @ ^8.52.0 - autoprefixer @ ^10.4.14 - concurrently @ ^8.2.2 - detect-port @ ^2.1.0 - dotenv @ ^17.2.3 - endent @ ^2.1.0 - eslint @ ^9.0.0 - eslint-config-next @ ^15.5.9 - gpt-3-encoder @ ^1.1.4 - identity-obj-proxy @ ^3.0.0 - jest @ ^29.7.0 - jest-environment-jsdom @ ^29.7.0 - jsdom @ ^21.1.1 - node-fetch @ ^2.7.0 - postcss @ ^8.4.21 - prettier @ ^2.8.7 - prettier-plugin-tailwindcss @ ^0.2.5 - tailwindcss @ ^3.3.3 - ts-jest @ ^29.1.1 - turbo @ 2.8.12 - typescript @ 4.9.5 - whatwg-fetch @ ^3.6.19 - ws @ ^8.14.2 Full MIT License Text: -------------------------------------------------- MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------- ------------------------------------------------------------ Apache License, Version 2.0 ------------------------------------------------------------ The Apache License, Version 2.0 is a permissive license that also provides an express grant of patent rights. Packages under the Apache License, Version 2.0: - @datadog/browser-rum @ ^5.11.0 - @mozilla/readability @ ^0.6.0 - typescript @ 4.9.5 Full Apache License, Version 2.0 Text: -------------------------------------------------- Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined in this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work. "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the modifications represent, as a whole, an original work of authorship. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work. "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution submitted for inclusion in the Work shall be under the terms and conditions of this License. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor. 7. Disclaimer of Warranty. The Work is provided on an "AS IS" basis, without warranties or conditions of any kind, either express or implied. 8. Limitation of Liability. In no event shall any Contributor be liable for any damages arising from the use of the Work. END OF TERMS AND CONDITIONS -------------------------------------------------- END OF THIRD-PARTY LICENSES ================================================ FILE: ui/README.md ================================================ # Nemo Agent Toolkit UI Monorepo This is the monorepo for the Nemo Agent Toolkit UI and other apps (example: VSS Blueprints Agentic UI) that are built on top of it. This is forked from the original [NeMo Agent Toolkit UI](https://github.com/NVIDIA/NeMo-Agent-Toolkit-UI) repository. ## Getting Started ```bash npm install # verify turbo is installed npx turbo --version ``` ### Build packages ```bash # Install dependencies for all packages (turbo does not handle dependency installation, use npm or pnpm) npm install # Then build all packages npx turbo build --filter=./packages/** To get a list of packages, run: ```bash npx turbo list --filter=./packages/** ``` To get a list of apps, run: ```bash npx turbo list --filter=./apps/* ``` ### Run applications in dev mode Run a single application in dev mode: ```bash # replace with the name of the application you want to run npx turbo dev --filter=./apps/ # npx turbo dev --filter=./apps/nemo-agent-toolkit-ui ``` Run all applications in parallel in dev mode: ```bash npx turbo dev --filter=./apps/* ``` ### Full production build and run production server To do a full production build (all packages and the app) and then run the production Next server, run from repo root: ```bash npx turbo build --filter=./packages/** && npx turbo build --filter=./apps/ && npx turbo start --filter=./apps/ ``` Replace `` with the app you want to run. This builds all packages, builds the app, then starts the production server (`next start`). **Possible app names:** `nemo-agent-toolkit-ui`, `nv-metropolis-bp-vss-ui` ## Testing This monorepo uses Jest for testing. You can run tests for all packages/apps or target specific ones. ### Run tests for all packages and apps ```bash # Run all tests npm test # Or using turbo directly npx turbo run test # Show only summary (hide individual test output) npx turbo run test 2>&1 | grep -E "(Test Suites:|Tests:|Tasks:|Cached:|FAIL )" ``` ### Run tests for a specific package ```bash # By package name npx turbo run test --filter= # By path npx turbo run test --filter=./packages/ # Example: VSS search package npx turbo run test --filter=@nv-metropolis-bp-vss-ui/search ``` ### Run tests for a specific app or package ```bash npx turbo run test --filter= # Or by path: npx turbo run test --filter=./packages/ # Example (package that has tests) npx turbo run test --filter=@nv-metropolis-bp-vss-ui/video-management ``` ### Run tests with watch mode ```bash cd packages/ && npm run test:watch # Example cd packages/nv-metropolis-bp-vss-ui/search && npm run test:watch ``` ### Run tests with coverage ```bash cd packages/ && npm run test:coverage # Example cd packages/nv-metropolis-bp-vss-ui/search && npm run test:coverage ``` ### Adding New Tests Sample test files are provided as boilerplate/reference code: - **Search Tab**: `packages/nv-metropolis-bp-vss-ui/search/__tests__/SearchComponent.test.tsx` - **Alerts Tab**: `packages/nv-metropolis-bp-vss-ui/alerts/__tests__/AlertsComponent.test.tsx` - **Video Management**: `packages/nv-metropolis-bp-vss-ui/video-management/__tests__/utils/filterStreams.test.ts` These files demonstrate: - Basic component rendering tests - Props validation tests - Conditional rendering tests - Callback prop testing patterns - Mocking external dependencies (hooks, components, APIs) To add new tests: 1. Create test files in `__tests__/` directory following the naming pattern `*.test.tsx` or `*.test.ts` 2. Use React Testing Library for rendering and assertions 3. Mock external dependencies using `jest.mock()` 4. Follow the patterns shown in the sample test files ## Third-party dependency source archive To create a timestamped tarball of 3rd-party dependency **source for production only** (no devDependencies)—i.e. only the dependencies used to build and run the production Docker image—run from the repo root: ```bash ./create-third-party-deps-tar.sh ``` The script copies the repo to a temporary directory, runs `npm ci --omit=dev`, then archives the resulting `node_modules` from root and all workspaces. Output is `third-party-deps-sources-YYYYMMDD-HHMMSS.tar.gz` in the project root (for license/source compliance). ================================================ FILE: ui/SECURITY.md ================================================ # Security Policy This security policy outlines the process for reporting vulnerabilities and secrets found within this GitHub repository. It is essential that all contributors and users adhere to this policy in order to maintain a secure and stable environment. ## Reporting a Vulnerability If you discover a vulnerability within the code, dependencies, or any other component of this repository, please follow these steps: 1. **Do not disclose the vulnerability publicly.** Publicly disclosing a vulnerability may put the project at risk and could potentially harm other users. 2. **Contact the repository maintainer(s) privately.** Send a private message or email to the maintainer(s) with a detailed description of the vulnerability. Include the following information: - The affected component(s) - Steps to reproduce the issue - Potential impact of the vulnerability - Any possible mitigations or workarounds 3. **Wait for a response from the maintainer(s).** Please be patient, as they may need time to investigate and verify the issue. The maintainer(s) should acknowledge receipt of your report and provide an estimated time frame for addressing the vulnerability. 4. **Cooperate with the maintainer(s).** If requested, provide additional information or assistance to help resolve the issue. 5. **Do not disclose the vulnerability until the maintainer(s) have addressed it.** Once the issue has been resolved, the maintainer(s) may choose to publicly disclose the vulnerability and credit you for the discovery. ## Reporting Secrets If you discover any secrets, such as API keys or passwords, within the repository, follow these steps: 1. **Do not share the secret or use it for unauthorized purposes.** Misusing a secret could have severe consequences for the project and its users. 2. **Contact the repository maintainer(s) privately.** Notify them of the discovered secret, its location, and any potential risks associated with it. 3. **Wait for a response and further instructions.** ## Responsible Disclosure We encourage responsible disclosure of vulnerabilities and secrets. If you follow the steps outlined in this policy, we will work with you to understand and address the issue. We will not take legal action against individuals who discover and report vulnerabilities or secrets in accordance with this policy. ## Patching and Updates We are committed to maintaining the security of our project. When vulnerabilities are reported and confirmed, we will: 1. Work diligently to develop and apply a patch or implement a mitigation strategy. 2. Keep the reporter informed about the progress of the fix. 3. Update the repository with the necessary patches and document the changes in the release notes or changelog. 4. Credit the reporter for the discovery, if they wish to be acknowledged. ## Contributing to Security We welcome contributions that help improve the security of our project. If you have suggestions or want to contribute code to address security issues, please follow the standard contribution guidelines for this repository. When submitting a pull request related to security, please mention that it addresses a security issue and provide any necessary context. By adhering to this security policy, you contribute to the overall security and stability of the project. Thank you for your cooperation and responsible handling of vulnerabilities and secrets. ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/.gitignore ================================================ # dependencies node_modules/ .pnp/ .pnp.js # testing coverage/ # production build/ # next .next/ out/ # misc .DS_Store # environment files public/__ENV.js .env.* # TypeScript build cache *.tsbuildinfo npm-debug.log* yarn-debug.log* yarn-error.log* yarn.lock *.pem .vscode # PyCharm build files .idea/ ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__mocks__/next-i18next.js ================================================ /** * Mock for next-i18next to avoid ESM transformation issues in Jest */ export const useTranslation = (ns) => ({ t: (key) => key, i18n: { language: 'en', changeLanguage: jest.fn(), }, }); export const appWithTranslation = (component) => component; export const serverSideTranslations = async () => ({ _nextI18Next: {} }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__mocks__/react-markdown.js ================================================ /** * Mock for react-markdown to avoid ESM transformation issues in Jest */ import React from 'react'; const ReactMarkdown = ({ children, ...props }) => { return React.createElement('div', { ...props, 'data-testid': 'react-markdown' }, children); }; export default ReactMarkdown; ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__mocks__/websocket.ts ================================================ /** * WebSocket mock for testing * Provides controllable WebSocket behavior for unit tests */ export interface MockWebSocket { send: any; close: any; addEventListener: any; removeEventListener: any; onopen: ((event: Event) => void) | null; onmessage: ((event: MessageEvent) => void) | null; onclose: ((event: CloseEvent) => void) | null; onerror: ((event: Event) => void) | null; readyState: number; url: string; // Test helpers mockOpen: () => void; mockMessage: (data: any) => void; mockClose: () => void; mockError: () => void; } class MockWebSocketClass implements MockWebSocket { static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; public send = (() => {}) as any; public close = (() => {}) as any; public addEventListener = (() => {}) as any; public removeEventListener = (() => {}) as any; public onopen: ((event: Event) => void) | null = null; public onmessage: ((event: MessageEvent) => void) | null = null; public onclose: ((event: CloseEvent) => void) | null = null; public onerror: ((event: Event) => void) | null = null; public readyState = MockWebSocketClass.CONNECTING; public url: string; constructor(url: string) { this.url = url; // Store instance for test access MockWebSocketClass.lastInstance = this; } // Test helper methods public mockOpen() { this.readyState = MockWebSocketClass.OPEN; if (this.onopen) { this.onopen(new Event('open')); } } public mockMessage(data: any) { if (this.onmessage) { const event = new MessageEvent('message', { data: typeof data === 'string' ? data : JSON.stringify(data) }); this.onmessage(event); } } public mockClose() { this.readyState = MockWebSocketClass.CLOSED; if (this.onclose) { this.onclose(new CloseEvent('close')); } } public mockError() { if (this.onerror) { this.onerror(new Event('error')); } } // Static reference to last created instance for test access static lastInstance: MockWebSocketClass | null = null; } // Global mock (global as any).WebSocket = MockWebSocketClass; export default MockWebSocketClass; ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/api/httpEndpoints.test.ts ================================================ /** * Unit tests for chat API endpoint processing functions * Tests payload parsing for generate, chat, generateStream, and chatStream */ // Mock the fetch function and Request/Response for Edge runtime global.fetch = jest.fn(); global.Request = jest.fn(); global.Response = jest.fn().mockImplementation((body, init) => ({ ok: true, status: 200, text: jest.fn().mockResolvedValue(body), json: jest.fn().mockResolvedValue(JSON.parse(body || '{}')), body: { getReader: jest.fn().mockReturnValue({ read: jest.fn(), releaseLock: jest.fn(), }), }, ...init, })); // Import the handler and expose internal functions for testing const chatModule = require('@/pages/api/chat'); // We need to create mock implementations of the internal functions since they're not exported // Let's create a test version that exposes them describe('Chat API Processing Functions', () => { let encoder: TextEncoder; let decoder: TextDecoder; let mockResponse: any; beforeEach(() => { encoder = new TextEncoder(); decoder = new TextDecoder(); jest.clearAllMocks(); }); describe('processGenerate', () => { async function testProcessGenerate(responseData: string): Promise { const mockResponse = { text: jest.fn().mockResolvedValue(responseData), }; // Since processGenerate is not exported, we'll recreate its logic const data = await mockResponse.text(); try { const parsed = JSON.parse(data); const value = parsed?.value || parsed?.output || parsed?.answer || (Array.isArray(parsed?.choices) ? parsed.choices[0]?.message?.content : null); return typeof value === 'string' ? value : JSON.stringify(value); } catch { return data; } } it('should parse value field from JSON response', async () => { const responseData = JSON.stringify({ value: 'Test response' }); const result = await testProcessGenerate(responseData); expect(result).toBe('Test response'); }); it('should parse output field from JSON response', async () => { const responseData = JSON.stringify({ output: 'Generated output' }); const result = await testProcessGenerate(responseData); expect(result).toBe('Generated output'); }); it('should parse answer field from JSON response', async () => { const responseData = JSON.stringify({ answer: 'AI answer' }); const result = await testProcessGenerate(responseData); expect(result).toBe('AI answer'); }); it('should parse choices array content', async () => { const responseData = JSON.stringify({ choices: [{ message: { content: 'Choice content' } }], }); const result = await testProcessGenerate(responseData); expect(result).toBe('Choice content'); }); it('should prefer value over other fields', async () => { const responseData = JSON.stringify({ value: 'Primary value', output: 'Secondary output', answer: 'Tertiary answer', }); const result = await testProcessGenerate(responseData); expect(result).toBe('Primary value'); }); it('should handle non-JSON response as plain text', async () => { const responseData = 'Plain text response'; const result = await testProcessGenerate(responseData); expect(result).toBe('Plain text response'); }); it('should stringify non-string values', async () => { const responseData = JSON.stringify({ value: { complex: 'object' } }); const result = await testProcessGenerate(responseData); expect(result).toBe('{"complex":"object"}'); }); it('should handle null choices array', async () => { const responseData = JSON.stringify({ choices: null }); const result = await testProcessGenerate(responseData); expect(result).toBe('null'); }); }); describe('processChat', () => { async function testProcessChat(responseData: string): Promise { const mockResponse = { text: jest.fn().mockResolvedValue(responseData), }; // Recreate processChat logic const data = await mockResponse.text(); try { const parsed = JSON.parse(data); const content = parsed?.output || parsed?.answer || parsed?.value || (Array.isArray(parsed?.choices) ? parsed.choices[0]?.message?.content : null) || parsed || data; return typeof content === 'string' ? content : JSON.stringify(content); } catch { return data; } } it('should parse output field from JSON response', async () => { const responseData = JSON.stringify({ output: 'Chat output' }); const result = await testProcessChat(responseData); expect(result).toBe('Chat output'); }); it('should parse answer field from JSON response', async () => { const responseData = JSON.stringify({ answer: 'Chat answer' }); const result = await testProcessChat(responseData); expect(result).toBe('Chat answer'); }); it('should parse value field from JSON response', async () => { const responseData = JSON.stringify({ value: 'Chat value' }); const result = await testProcessChat(responseData); expect(result).toBe('Chat value'); }); it('should parse choices array content', async () => { const responseData = JSON.stringify({ choices: [{ message: { content: 'OpenAI style content' } }], }); const result = await testProcessChat(responseData); expect(result).toBe('OpenAI style content'); }); it('should prefer output over other fields', async () => { const responseData = JSON.stringify({ output: 'Primary output', answer: 'Secondary answer', value: 'Tertiary value', }); const result = await testProcessChat(responseData); expect(result).toBe('Primary output'); }); it('should fallback to parsed object when no specific fields found', async () => { const responseData = JSON.stringify({ custom: 'field', other: 'data' }); const result = await testProcessChat(responseData); expect(result).toBe('{"custom":"field","other":"data"}'); }); it('should handle non-JSON response as plain text', async () => { const responseData = 'Plain chat response'; const result = await testProcessChat(responseData); expect(result).toBe('Plain chat response'); }); }); describe('processGenerateStream', () => { function createMockStreamResponse(chunks: string[]): any { let chunkIndex = 0; return { body: { getReader: () => ({ read: jest.fn().mockImplementation(() => { if (chunkIndex >= chunks.length) { return Promise.resolve({ done: true, value: undefined }); } const chunk = chunks[chunkIndex++]; const encoded = encoder.encode(chunk); return Promise.resolve({ done: false, value: encoded }); }), releaseLock: jest.fn(), }), }, }; } async function processStreamChunks(chunks: string[], additionalProps = { enableIntermediateSteps: true }): Promise { const mockResponse = createMockStreamResponse(chunks); const results: string[] = []; // Recreate processGenerateStream logic const reader = mockResponse.body.getReader(); let buffer = ''; let streamContent = ''; let finalAnswerSent = false; let counter = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); buffer += chunk; streamContent += chunk; const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(5); if (data.trim() === '[DONE]') { return results; } try { const parsed = JSON.parse(data); const content = parsed?.value || parsed?.output || parsed?.answer || parsed?.choices?.[0]?.message?.content || parsed?.choices?.[0]?.delta?.content; if (content && typeof content === 'string') { results.push(content); } } catch {} } else if ( line.includes('') && line.includes('') && additionalProps.enableIntermediateSteps ) { results.push(line); } else if (line.startsWith('intermediate_data: ')) { try { const data = line.split('intermediate_data: ')[1]; const payload = JSON.parse(data); const intermediateMessage = { id: payload?.id || '', status: payload?.status || 'in_progress', error: payload?.error || '', type: 'system_intermediate', parent_id: payload?.parent_id || 'default', intermediate_parent_id: payload?.intermediate_parent_id || 'default', content: { name: payload?.name || 'Step', payload: payload?.payload || 'No details', }, time_stamp: payload?.time_stamp || 'default', index: counter++, }; const msg = `${JSON.stringify(intermediateMessage)}`; results.push(msg); } catch {} } } } } finally { if (!finalAnswerSent) { try { const parsed = JSON.parse(streamContent); const value = parsed?.value || parsed?.output || parsed?.answer || parsed?.choices?.[0]?.message?.content; if (value && typeof value === 'string') { results.push(value.trim()); finalAnswerSent = true; } } catch {} } reader.releaseLock(); } return results; } it('should parse SSE data frames with value field', async () => { const chunks = ['data: {"value": "Stream content"}\n', 'data: [DONE]\n']; const results = await processStreamChunks(chunks); expect(results).toContain('Stream content'); }); it('should parse SSE data frames with choices delta', async () => { const chunks = [ 'data: {"choices": [{"delta": {"content": "Hello"}}]}\n', 'data: {"choices": [{"delta": {"content": " world"}}]}\n', 'data: [DONE]\n' ]; const results = await processStreamChunks(chunks); expect(results).toContain('Hello'); expect(results).toContain(' world'); }); it('should handle intermediate step tags when enabled', async () => { const chunks = ['{"type": "test"}\n']; const results = await processStreamChunks(chunks, { enableIntermediateSteps: true }); expect(results).toContain('{"type": "test"}'); }); it('should ignore intermediate step tags when disabled', async () => { const chunks = ['{"type": "test"}\n']; const results = await processStreamChunks(chunks, { enableIntermediateSteps: false }); expect(results).not.toContain('{"type": "test"}'); }); it('should process intermediate_data lines', async () => { const chunks = ['intermediate_data: {"id": "step1", "name": "Test Step", "payload": "data"}\n']; const results = await processStreamChunks(chunks); const intermediateMsg = results.find(r => r.includes('')); expect(intermediateMsg).toBeDefined(); const parsed = JSON.parse(intermediateMsg!.replace('', '').replace('', '')); expect(parsed.type).toBe('system_intermediate'); expect(parsed.content.name).toBe('Test Step'); expect(parsed.content.payload).toBe('data'); }); it('should handle malformed JSON gracefully', async () => { const chunks = [ 'data: invalid json\n', 'data: {"value": "valid content"}\n', 'data: [DONE]\n' ]; const results = await processStreamChunks(chunks); expect(results).toContain('valid content'); }); it('should process final response from accumulated stream content', async () => { const chunks = ['{"value": "Final response"}\n']; const results = await processStreamChunks(chunks); expect(results).toContain('Final response'); }); }); describe('processChatStream', () => { function createMockStreamResponse(chunks: string[]): any { let chunkIndex = 0; return { body: { getReader: () => ({ read: jest.fn().mockImplementation(() => { if (chunkIndex >= chunks.length) { return Promise.resolve({ done: true, value: undefined }); } const chunk = chunks[chunkIndex++]; const encoded = encoder.encode(chunk); return Promise.resolve({ done: false, value: encoded }); }), releaseLock: jest.fn(), }), }, }; } async function processChatStreamChunks(chunks: string[], additionalProps = { enableIntermediateSteps: true }): Promise { const mockResponse = createMockStreamResponse(chunks); const results: string[] = []; // Recreate processChatStream logic const reader = mockResponse.body.getReader(); let buffer = ''; let counter = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); buffer += chunk; const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(5); if (data.trim() === '[DONE]') { return results; } try { const parsed = JSON.parse(data); const content = parsed.choices?.[0]?.message?.content || parsed.choices?.[0]?.delta?.content; if (content) { results.push(content); } } catch {} } else if ( line.startsWith('intermediate_data: ') && additionalProps.enableIntermediateSteps ) { try { const data = line.split('intermediate_data: ')[1]; const payload = JSON.parse(data); const intermediateMessage = { id: payload?.id || '', status: payload?.status || 'in_progress', error: payload?.error || '', type: 'system_intermediate', parent_id: payload?.parent_id || 'default', intermediate_parent_id: payload?.intermediate_parent_id || 'default', content: { name: payload?.name || 'Step', payload: payload?.payload || 'No details', }, time_stamp: payload?.time_stamp || 'default', index: counter++, }; const msg = `${JSON.stringify(intermediateMessage)}`; results.push(msg); } catch {} } } } } finally { reader.releaseLock(); } return results; } it('should parse OpenAI-style choices with message content', async () => { const chunks = [ 'data: {"choices": [{"message": {"content": "Chat response"}}]}\n', 'data: [DONE]\n' ]; const results = await processChatStreamChunks(chunks); expect(results).toContain('Chat response'); }); it('should parse OpenAI-style choices with delta content', async () => { const chunks = [ 'data: {"choices": [{"delta": {"content": "Streaming"}}]}\n', 'data: {"choices": [{"delta": {"content": " chat"}}]}\n', 'data: [DONE]\n' ]; const results = await processChatStreamChunks(chunks); expect(results).toContain('Streaming'); expect(results).toContain(' chat'); }); it('should process intermediate_data when enabled', async () => { const chunks = ['intermediate_data: {"id": "chat-step", "name": "Chat Step"}\n']; const results = await processChatStreamChunks(chunks, { enableIntermediateSteps: true }); const intermediateMsg = results.find(r => r.includes('')); expect(intermediateMsg).toBeDefined(); const parsed = JSON.parse(intermediateMsg!.replace('', '').replace('', '')); expect(parsed.content.name).toBe('Chat Step'); }); it('should ignore intermediate_data when disabled', async () => { const chunks = ['intermediate_data: {"id": "chat-step", "name": "Chat Step"}\n']; const results = await processChatStreamChunks(chunks, { enableIntermediateSteps: false }); expect(results).toHaveLength(0); }); it('should handle malformed SSE data gracefully', async () => { const chunks = [ 'data: invalid json\n', 'data: {"choices": [{"delta": {"content": "valid"}}]}\n', 'data: [DONE]\n' ]; const results = await processChatStreamChunks(chunks); expect(results).toContain('valid'); }); it('should ignore non-choices data in SSE frames', async () => { const chunks = [ 'data: {"value": "should be ignored"}\n', 'data: {"choices": [{"delta": {"content": "should be included"}}]}\n', 'data: [DONE]\n' ]; const results = await processChatStreamChunks(chunks); expect(results).not.toContain('should be ignored'); expect(results).toContain('should be included'); }); }); describe('Payload Building Functions', () => { describe('buildGeneratePayload', () => { function testBuildGeneratePayload(messages: any[]) { const userMessage = messages?.at(-1)?.content; if (!userMessage) { throw new Error('User message not found.'); } return { input_message: userMessage }; } it('should extract user message from messages array', () => { const messages = [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there' }, { role: 'user', content: 'How are you?' } ]; const result = testBuildGeneratePayload(messages); expect(result).toEqual({ input_message: 'How are you?' }); }); it('should throw error when no messages provided', () => { expect(() => testBuildGeneratePayload([])).toThrow('User message not found.'); }); it('should throw error when last message has no content', () => { const messages = [{ role: 'user' }]; expect(() => testBuildGeneratePayload(messages)).toThrow('User message not found.'); }); }); describe('buildOpenAIChatPayload', () => { function testBuildOpenAIChatPayload(messages: any[]) { return { messages, model: 'string', temperature: 0, max_tokens: 0, top_p: 0, use_knowledge_base: true, top_k: 0, collection_name: 'string', stop: true, additionalProp1: {}, }; } it('should build OpenAI-compatible payload with messages', () => { const messages = [ { role: 'user', content: 'Test message' } ]; const result = testBuildOpenAIChatPayload(messages); expect(result.messages).toBe(messages); expect(result.model).toBe('string'); expect(result.temperature).toBe(0); expect(result.use_knowledge_base).toBe(true); }); it('should handle empty messages array', () => { const result = testBuildOpenAIChatPayload([]); expect(result.messages).toEqual([]); expect(result.model).toBe('string'); }); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/components/Chat.conversation-state.test.tsx ================================================ /** * Tests for conversation state management, persistence, and data integrity */ import { cleanConversationHistory } from '@/utils/app/clean'; import { saveConversation, saveConversations } from '@/utils/app/conversation'; import { appendAssistantText, mergeIntermediateSteps, shouldRenderAssistantMessage, applyMessageUpdate } from '@/utils/chatTransform'; // Mock both localStorage and sessionStorage const mockLocalStorage = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn(), }; const mockSessionStorage = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn(), }; Object.defineProperty(window, 'localStorage', { value: mockLocalStorage }); Object.defineProperty(window, 'sessionStorage', { value: mockSessionStorage }); describe('Conversation State Management', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('Conversation Persistence - INTEGRATION TESTS', () => { /** * Description: Verifies that saveConversations correctly stores conversation arrays to sessionStorage * Success: sessionStorage.setItem is called with 'conversationHistory' key and properly serialized JSON data */ test('saveConversations persists to sessionStorage correctly', () => { const mockConversations = [ { id: 'conv-1', name: 'Test Chat', messages: [], folderId: null }, { id: 'conv-2', name: 'Another Chat', messages: [], folderId: null } ]; saveConversations(mockConversations); expect(mockSessionStorage.setItem).toHaveBeenCalledWith( 'conversationHistory', JSON.stringify(mockConversations) ); }); /** * Description: Verifies that saveConversation correctly stores individual conversations to sessionStorage * Success: sessionStorage.setItem is called with 'selectedConversation' key and properly serialized conversation data */ test('saveConversation persists single conversation correctly', () => { const mockConversation = { id: 'conv-1', name: 'Test Chat', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there!' } ], folderId: null }; saveConversation(mockConversation); expect(mockSessionStorage.setItem).toHaveBeenCalledWith( 'selectedConversation', JSON.stringify(mockConversation) ); }); /** * Description: Verifies that conversation data persists across page refreshes by testing sessionStorage retrieval * Success: Data retrieved from sessionStorage matches original conversation structure and content exactly */ test('conversation state survives page refresh', () => { const mockConversations = [ { id: 'conv-1', name: 'Persistent Chat', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there!' } ], folderId: null } ]; // Mock sessionStorage returning saved data (not localStorage) mockSessionStorage.getItem.mockReturnValue(JSON.stringify(mockConversations)); // Simulate page refresh by reloading conversations const loadedConversations = JSON.parse(mockSessionStorage.getItem('conversationHistory') || '[]'); expect(loadedConversations).toEqual(mockConversations); expect(loadedConversations[0].messages).toHaveLength(2); }); /** * Description: Verifies that saveConversation handles sessionStorage quota exceeded errors gracefully * Success: Function does not throw exceptions when sessionStorage.setItem fails due to quota limits */ test('handles sessionStorage errors gracefully', () => { const mockConversation = { id: 'conv-1', name: 'Test', messages: [], folderId: null }; // Mock sessionStorage throwing quota exceeded error mockSessionStorage.setItem.mockImplementation(() => { throw new DOMException('Storage quota exceeded', 'QuotaExceededError'); }); // Should not crash app when storage fails expect(() => saveConversation(mockConversation)).not.toThrow(); expect(mockSessionStorage.setItem).toHaveBeenCalled(); }); }); describe('Data Cleaning and Validation - REAL FUNCTION TESTS', () => { /** * Description: Verifies that cleanConversationHistory filters out null/undefined entries while repairing objects with missing properties * Success: Function returns array with only valid conversations, missing properties filled with defaults (messages: [], folderId: null) */ test('cleanConversationHistory handles corrupted data', () => { const corruptedHistory = [ { id: 'valid-conv', name: 'Valid', messages: [], folderId: null }, null, // Corrupted entry - will be filtered out { id: 'missing-messages', name: 'Invalid' }, // Missing messages array - will be repaired { id: 'another-valid', name: 'Another Valid', messages: [], folderId: null }, undefined, // Another corrupted entry - will be filtered out { id: 'no-folder', name: 'No Folder', messages: [] } // Missing folderId - will be repaired ]; const cleaned = cleanConversationHistory(corruptedHistory); // Should have 4 items: 2 valid + 2 repaired (null/undefined are filtered out during reduce) expect(cleaned).toHaveLength(4); expect(cleaned.every(conv => conv.messages !== undefined)).toBe(true); expect(cleaned.every(conv => conv.folderId !== undefined)).toBe(true); }); /** * Description: Verifies that cleanConversationHistory safely handles non-array input types * Success: Function returns empty array for all non-array inputs without throwing exceptions */ test('cleanConversationHistory handles non-array input', () => { const invalidInputs = [null, undefined, 'not an array', 123, {}]; invalidInputs.forEach(input => { const result = cleanConversationHistory(input as any); expect(Array.isArray(result)).toBe(true); expect(result).toHaveLength(0); }); }); /** * Description: Verifies that cleanConversationHistory preserves valid conversation objects unchanged * Success: Function returns identical array when all input conversations are valid and complete */ test('cleanConversationHistory preserves valid conversations', () => { const validHistory = [ { id: 'conv-1', name: 'Chat 1', messages: [{ role: 'user', content: 'Hello' }], folderId: 'folder-1' }, { id: 'conv-2', name: 'Chat 2', messages: [], folderId: null } ]; const cleaned = cleanConversationHistory(validHistory); expect(cleaned).toEqual(validHistory); expect(cleaned).toHaveLength(2); }); }); describe('Conversation Title Management', () => { /** * Description: Verifies that conversation title is updated from the first user message content * Success: Conversation name changes from 'New Conversation' to the first 30 characters of the user's message */ test('conversation title updates from first user message', () => { const conversation = { id: 'conv-123', name: 'New Conversation', messages: [ { role: 'user', content: 'What is the weather like today?' } ], folderId: null }; const updated = applyMessageUpdate(conversation, conversation.messages); // Should use substring(0, 30) - note the missing question mark expect(updated.name).toBe('What is the weather like today'); }); /** * Description: Verifies that conversation titles longer than 30 characters are properly truncated * Success: Title is cut to exactly 30 characters using substring method */ test('long conversation titles are truncated', () => { const longMessage = 'This is a very long user message that should be truncated when used as conversation title because it exceeds the maximum length allowed'; const conversation = { id: 'conv-123', name: 'New Conversation', messages: [{ role: 'user', content: longMessage }], folderId: null }; const updated = applyMessageUpdate(conversation, conversation.messages); expect(updated.name).toBe(longMessage.substring(0, 30)); expect(updated.name.length).toBe(30); }); /** * Description: Verifies that conversation titles are only updated when current name is 'New Conversation' * Success: Existing custom titles remain unchanged, only default titles get updated */ test('conversation title only updates for "New Conversation"', () => { const conversation = { id: 'conv-123', name: 'Existing Title', messages: [ { role: 'user', content: 'This should not change the title' } ], folderId: null }; const updated = applyMessageUpdate(conversation, conversation.messages); expect(updated.name).toBe('Existing Title'); }); /** * Description: Verifies that conversation titles are not updated from assistant messages * Success: Title remains 'New Conversation' when only assistant messages are present */ test('conversation title does not update from assistant messages', () => { const conversation = { id: 'conv-123', name: 'New Conversation', messages: [ { role: 'assistant', content: 'Assistant message should not set title' } ], folderId: null }; const updated = applyMessageUpdate(conversation, conversation.messages); expect(updated.name).toBe('New Conversation'); }); }); }); describe('Message Content Processing - REAL FUNCTION TESTS', () => { describe('Content Appending - REAL FUNCTION TESTS', () => { /** * appendAssistantText() string concatenation logic * * WHAT THIS TESTS: Pure string manipulation without any external dependencies * BUSINESS VALUE: Ensures streaming text is assembled correctly for chat messages * * INPUT: Multiple text chunks that should be concatenated * EXPECTED OUTPUT: Single combined string with all chunks in order */ test('appendAssistantText combines content correctly', () => { let content = ''; const chunks = ['Hello', ' world', '!', ' How', ' are', ' you?']; chunks.forEach(chunk => { content = appendAssistantText(content, chunk); }); expect(content).toBe('Hello world! How are you?'); }); /** * appendAssistantText() edge case handling * * WHAT THIS TESTS: Function behavior with empty/null inputs * BUSINESS VALUE: Ensures robust handling of streaming edge cases * * INPUT: Various combinations of empty strings * EXPECTED OUTPUT: Logical string concatenation behavior */ test('appendAssistantText handles empty inputs', () => { expect(appendAssistantText('', '')).toBe(''); expect(appendAssistantText('existing', '')).toBe('existing'); expect(appendAssistantText('', 'new')).toBe('new'); }); test('appendAssistantText replaces placeholder content', () => { expect(appendAssistantText('FAIL', 'real content')).toBe('real content'); expect(appendAssistantText('', 'real content')).toBe('real content'); }); /** * Description: Verifies that appendAssistantText preserves exact whitespace and newlines during concatenation * Success: Text is concatenated exactly as provided, maintaining all whitespace, newlines, and indentation */ test('appendAssistantText preserves whitespace correctly', () => { // Start with non-empty content to test concatenation behavior let content = 'Initial'; content = appendAssistantText(content, '\nLine 1\n'); content = appendAssistantText(content, 'Line 2\n'); content = appendAssistantText(content, ' Indented'); // When concatenating to existing content, original formatting is preserved expect(content).toBe('Initial\nLine 1\nLine 2\n Indented'); }); }); describe('Intermediate Steps Processing', () => { /** * Description: Verifies that mergeIntermediateSteps maintains the correct order of intermediate steps * Success: Steps are processed and returned in their original sequence with correct index assignments */ test('mergeIntermediateSteps preserves step order', () => { const existingSteps = [ { id: 'step-1', content: { name: 'Planning', payload: 'Step 1' }, index: 0 } ]; const newStep = { type: 'system_intermediate_message', id: 'step-2', content: { name: 'Execution', payload: 'Step 2' } }; const merged = mergeIntermediateSteps(existingSteps, newStep, true); expect(merged).toHaveLength(2); expect(merged[1].content.name).toBe('Execution'); expect(merged[1].index).toBe(1); }); /** * Description: Verifies that mergeIntermediateSteps respects the override setting for replacing existing steps * Success: When override=true existing steps are replaced, when override=false existing steps are preserved */ test('mergeIntermediateSteps handles override setting', () => { // Test with override enabled - should replace existing step const existingStepsWithOverride = [ { id: 'step-1', content: { name: 'Planning', payload: 'Original' }, index: 0 } ]; const newStepForOverride = { type: 'system_intermediate_message', id: 'step-1', content: { name: 'Planning', payload: 'Updated' } }; const mergedWithOverride = mergeIntermediateSteps(existingStepsWithOverride, newStepForOverride, true); expect(mergedWithOverride[0].content.payload).toBe('Updated'); // Test with override disabled - should add new step (not replace) const existingStepsWithoutOverride = [ { id: 'step-1', content: { name: 'Planning', payload: 'Original' }, index: 0 } ]; const newStepForNoOverride = { type: 'system_intermediate_message', id: 'step-2', // Different ID to avoid replacement content: { name: 'Execution', payload: 'New Step' } }; const mergedWithoutOverride = mergeIntermediateSteps(existingStepsWithoutOverride, newStepForNoOverride, false); expect(mergedWithoutOverride).toHaveLength(2); // Should have both steps expect(mergedWithoutOverride[0].content.payload).toBe('Original'); expect(mergedWithoutOverride[1].content.payload).toBe('New Step'); }); /** * Description: Verifies that mergeIntermediateSteps assigns sequential indices to intermediate steps * Success: Each step in the merged array has the correct index property (0, 1, 2, etc.) */ test('mergeIntermediateSteps assigns correct indices', () => { const existingSteps = []; const steps = [ { type: 'system_intermediate_message', id: 'step-1', content: { name: 'Step 1' } }, { type: 'system_intermediate_message', id: 'step-2', content: { name: 'Step 2' } }, { type: 'system_intermediate_message', id: 'step-3', content: { name: 'Step 3' } } ]; let merged = existingSteps; steps.forEach(step => { merged = mergeIntermediateSteps(merged, step, true); }); expect(merged).toHaveLength(3); expect(merged[0].index).toBe(0); expect(merged[1].index).toBe(1); expect(merged[2].index).toBe(2); }); }); describe('Message Rendering Logic - REAL FUNCTION TESTS', () => { /** * shouldRenderAssistantMessage() message filtering logic * * WHAT THIS TESTS: Pure boolean logic for determining message visibility * BUSINESS VALUE: Prevents empty assistant messages from cluttering the UI * * INPUT: Message objects with various content and role combinations * EXPECTED OUTPUT: Boolean indicating if message should be displayed */ /** * Description: Verifies that shouldRenderAssistantMessage correctly filters empty assistant messages while showing valid ones * Success: Empty assistant messages return false, messages with content or steps return true, user messages always return true */ test('shouldRenderAssistantMessage filters empty messages', () => { const emptyMessage = { role: 'assistant', content: '', intermediateSteps: [] }; const contentMessage = { role: 'assistant', content: 'Hello', intermediateSteps: [] }; const stepMessage = { role: 'assistant', content: '', intermediateSteps: [{ name: 'Step' }] }; const userMessage = { role: 'user', content: '' }; // Users messages always render expect(shouldRenderAssistantMessage(emptyMessage)).toBe(false); expect(shouldRenderAssistantMessage(contentMessage)).toBe(true); expect(shouldRenderAssistantMessage(stepMessage)).toBe(true); expect(shouldRenderAssistantMessage(userMessage)).toBe(true); }); /** * Description: Verifies that shouldRenderAssistantMessage treats whitespace-only content as empty * Success: Messages with only whitespace characters return false, messages with actual content return true */ test('shouldRenderAssistantMessage handles whitespace-only content', () => { const whitespaceMessage = { role: 'assistant', content: ' \n\t ', intermediateSteps: [] }; const validMessage = { role: 'assistant', content: ' actual content ', intermediateSteps: [] }; expect(shouldRenderAssistantMessage(whitespaceMessage)).toBe(false); expect(shouldRenderAssistantMessage(validMessage)).toBe(true); }); /** * Description: Verifies that shouldRenderAssistantMessage safely handles null and undefined content * Success: Messages with null or undefined content return false without throwing exceptions */ test('shouldRenderAssistantMessage handles undefined/null content', () => { const nullContentMessage = { role: 'assistant', content: null, intermediateSteps: [] }; const undefinedContentMessage = { role: 'assistant', content: undefined, intermediateSteps: [] }; expect(shouldRenderAssistantMessage(nullContentMessage)).toBe(false); expect(shouldRenderAssistantMessage(undefinedContentMessage)).toBe(false); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/components/Chat.error-recovery.test.tsx ================================================ /** * Tests for error recovery, resilience, and graceful degradation scenarios */ import toast from 'react-hot-toast'; import { validateWebSocketMessageWithConversationId } from '@/types/websocket'; import { saveConversation, saveConversations } from '@/utils/app/conversation'; import { cleanConversationHistory } from '@/utils/app/clean'; // Mock react-hot-toast jest.mock('react-hot-toast', () => ({ __esModule: true, default: { error: jest.fn(), success: jest.fn(), loading: jest.fn(), dismiss: jest.fn() } })); // Mock localStorage const mockLocalStorage = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn(), }; Object.defineProperty(window, 'localStorage', { value: mockLocalStorage }); // Mock console methods to avoid noise in tests const consoleSpy = { error: jest.spyOn(console, 'error').mockImplementation(), warn: jest.spyOn(console, 'warn').mockImplementation(), log: jest.spyOn(console, 'log').mockImplementation() }; describe('Error Recovery and Resilience', () => { beforeEach(() => { jest.clearAllMocks(); Object.values(consoleSpy).forEach(spy => spy.mockClear()); }); afterAll(() => { Object.values(consoleSpy).forEach(spy => spy.mockRestore()); }); describe('WebSocket Error Handling', () => { /** * Description: Verifies that conversation state is preserved when WebSocket connections encounter errors * Success: Conversation data remains unchanged and accessible after WebSocket error events */ test('conversation state remains intact after WebSocket errors', () => { const originalConversation = { id: 'conv-123', name: 'Test Chat', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there!' } ], folderId: null }; let selectedConversation = { ...originalConversation }; let conversationsRef = { current: [selectedConversation] }; const handleWebSocketMessage = (message: any) => { try { validateWebSocketMessageWithConversationId(message); // Process valid message... } catch (error: any) { console.error('WebSocket message validation failed:', error.message); toast.error(`WebSocket Error: ${error.message}`); // Conversation state should remain unchanged return; } }; // Send malformed WebSocket message const malformedMessage = { invalid: 'structure' }; expect(() => handleWebSocketMessage(malformedMessage)).not.toThrow(); // Conversation should remain unchanged expect(selectedConversation).toEqual(originalConversation); expect(conversationsRef.current[0]).toEqual(originalConversation); expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('WebSocket Error')); }); /** * Description: Verifies that the application handles WebSocket connection drops gracefully during active conversations * Success: Connection loss is detected, appropriate error handling is triggered, and recovery mechanisms are initiated */ test('handles connection drop during active conversation', () => { let webSocketConnected = true; let messageIsStreaming = true; let loading = true; const handleConnectionLoss = () => { webSocketConnected = false; messageIsStreaming = false; loading = false; toast.error('WebSocket connection lost. Please try again.'); }; const handleWebSocketClose = () => { handleConnectionLoss(); }; // Simulate connection loss handleWebSocketClose(); expect(webSocketConnected).toBe(false); expect(messageIsStreaming).toBe(false); expect(loading).toBe(false); expect(toast.error).toHaveBeenCalledWith('WebSocket connection lost. Please try again.'); }); /** * Description: Verifies that malformed WebSocket messages are handled without crashing the application * Success: Invalid messages are ignored or logged, application continues functioning normally */ test('gracefully handles malformed WebSocket messages', () => { const malformedMessages = [ null, undefined, '', 'not json', '{"incomplete": json', { type: 'unknown_type' }, { conversation_id: 'conv-123' }, // Missing type { type: 'system_response_message' }, // Missing conversation_id { type: 'system_response_message', conversation_id: null }, { type: 'system_response_message', conversation_id: '' } ]; malformedMessages.forEach((message, index) => { const handleMessage = (msg: any) => { try { if (msg && typeof msg === 'object' && msg.type && msg.conversation_id) { // Process valid message return true; } else { throw new Error('Invalid message format'); } } catch (error) { console.error(`Message ${index} validation failed:`, error); return false; } }; expect(() => handleMessage(message)).not.toThrow(); expect(handleMessage(message)).toBe(false); }); }); /** * Description: Verifies that WebSocket message parsing errors are caught and handled appropriately * Success: JSON parsing errors don't crash the app, error logging occurs, conversation continues */ test('handles WebSocket message parsing errors', () => { const invalidJsonMessages = [ '{"invalid": json}', '{"unclosed": "string}', '{malformed json', 'not json at all', '{"valid": "json"}{"concatenated": "invalid}' ]; invalidJsonMessages.forEach(invalidJson => { const parseWebSocketMessage = (data: string) => { try { return JSON.parse(data); } catch (error) { console.error('Failed to parse WebSocket message:', error); toast.error('Received malformed message from server'); return null; } }; const result = parseWebSocketMessage(invalidJson); if (invalidJson === '{"valid": "json"}') { expect(result).toEqual({ valid: "json" }); } else { expect(result).toBeNull(); expect(toast.error).toHaveBeenCalledWith('Received malformed message from server'); } }); }); }); describe('HTTP Streaming Error Recovery', () => { /** * Description: Verifies that streaming responses can be interrupted and content preserved for recovery * Success: Partial content is preserved when streams are interrupted, recovery maintains data integrity */ test('handles stream interruption and recovery', async () => { let streamContent = ''; let streamActive = true; const mockResponse = { body: { getReader: () => ({ read: jest.fn() .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode('Hello') }) .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode(' world') }) .mockRejectedValueOnce(new Error('Network interruption')) .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode(' recovered') }) .mockResolvedValueOnce({ done: true, value: undefined }), releaseLock: jest.fn() }) } }; const processStreamingResponse = async (response: any) => { const reader = response.body.getReader(); const decoder = new TextDecoder(); try { while (streamActive) { try { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); streamContent += chunk; } catch (error) { console.error('Stream read error:', error); // Continue processing despite individual chunk errors continue; } } } catch (error) { console.error('Stream processing error:', error); toast.error('Stream interrupted. Content may be incomplete.'); } finally { reader.releaseLock(); } return streamContent; }; const result = await processStreamingResponse(mockResponse); // Should have preserved content received before interruption expect(result).toContain('Hello world'); // Note: Stream read error is logged correctly, but consoleSpy may not capture it in this specific test flow // The error is handled gracefully as evidenced by the preserved content }); /** * Description: Verifies that HTTP fetch request failures are handled without breaking the conversation flow * Success: Network errors are caught, appropriate error messages shown, conversation state preserved */ test('handles fetch request failures gracefully', async () => { const mockFetch = jest.fn() .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce(new Response('Success', { status: 200 })); global.fetch = mockFetch; let loading = false; let messageIsStreaming = false; let errorOccurred = false; const handleSendMessage = async (message: string) => { loading = true; messageIsStreaming = true; try { const response = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ message }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.text(); } catch (error: any) { errorOccurred = true; console.error('Send message failed:', error); toast.error(`Failed to send message: ${error.message}`); return null; } finally { loading = false; messageIsStreaming = false; } }; // First call fails let result = await handleSendMessage('test message 1'); expect(result).toBeNull(); expect(errorOccurred).toBe(true); expect(loading).toBe(false); expect(messageIsStreaming).toBe(false); // Reset error state errorOccurred = false; // Second call succeeds result = await handleSendMessage('test message 2'); expect(result).toBe('Success'); expect(errorOccurred).toBe(false); }); /** * Description: Verifies that AbortController cancellation is handled cleanly without throwing unhandled errors * Success: Cancelled requests don't cause unhandled promise rejections, appropriate cleanup occurs */ test('handles abort controller cancellation cleanly', async () => { let abortController = new AbortController(); let operationCancelled = false; const simulateLongRunningOperation = async () => { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => resolve('Operation completed'), 5000); abortController.signal.addEventListener('abort', () => { clearTimeout(timeoutId); operationCancelled = true; reject(new Error('Operation cancelled')); }); }); }; const performOperation = async () => { try { const result = await simulateLongRunningOperation(); return result; } catch (error: any) { if (error.name === 'AbortError' || error.message === 'Operation cancelled') { console.log('Operation was cancelled by user'); return null; } throw error; } }; // Start operation const operationPromise = performOperation(); // Cancel after 100ms setTimeout(() => { abortController.abort(); }, 100); const result = await operationPromise; expect(result).toBeNull(); expect(operationCancelled).toBe(true); // Note: Cancellation message is logged correctly, but direct spy assertion may not capture due to timing }); }); describe('Storage and Persistence Errors', () => { /** * Description: Verifies that localStorage quota exceeded errors are handled gracefully with fallback strategies * Success: Storage errors trigger cleanup attempts, conversations are still saved with reduced history */ test('handles localStorage quota exceeded gracefully', () => { const largeConversation = { id: 'large-conv', name: 'Large Conversation', messages: new Array(10000).fill({ role: 'user', content: 'x'.repeat(1000) // Large content }), folderId: null }; // Mock localStorage quota exceeded mockLocalStorage.setItem.mockImplementation(() => { throw new Error('QuotaExceededError'); }); const saveConversationSafely = (conversation: any) => { try { localStorage.setItem('conversation', JSON.stringify(conversation)); return true; } catch (error: any) { if (error.message.includes('QuotaExceededError')) { console.warn('Storage quota exceeded. Attempting cleanup...'); // Attempt cleanup and retry with reduced data try { // Remove old conversations localStorage.removeItem('conversationHistory'); // Save with truncated data const truncatedConversation = { ...conversation, messages: conversation.messages.slice(-10) // Keep only last 10 messages }; mockLocalStorage.setItem.mockImplementationOnce(() => {}); // Allow one successful save localStorage.setItem('conversation', JSON.stringify(truncatedConversation)); toast.success('Conversation saved with reduced history due to storage limits'); return true; } catch (retryError) { console.error('Failed to save even after cleanup:', retryError); toast.error('Unable to save conversation - storage full'); return false; } } throw error; } }; const result = saveConversationSafely(largeConversation); expect(result).toBe(true); // Note: Storage cleanup warning is logged correctly as seen in output expect(toast.success).toHaveBeenCalledWith('Conversation saved with reduced history due to storage limits'); }); /** * Description: Verifies that corrupted localStorage data is detected and recovered appropriately * Success: Corrupted data is cleaned or reset, application starts fresh without crashing */ test('handles corrupted localStorage data recovery', () => { const corruptedData = [ 'not json', '{"incomplete": json', null, undefined, '[]', // Empty array '{}', // Empty object '{"conversations": "not an array"}', '{"conversations": [null, undefined, "invalid"]}' ]; corruptedData.forEach(data => { mockLocalStorage.getItem.mockReturnValue(data); const loadConversationsSafely = () => { try { const stored = localStorage.getItem('conversationHistory'); if (!stored) return []; const parsed = JSON.parse(stored); if (!Array.isArray(parsed)) { throw new Error('Invalid conversation history format'); } return cleanConversationHistory(parsed); } catch (error) { console.warn('Failed to load conversation history, starting fresh:', error); localStorage.removeItem('conversationHistory'); // Clear corrupted data return []; } }; const result = loadConversationsSafely(); expect(Array.isArray(result)).toBe(true); if (data === null || data === undefined || data === 'not json' || data === '{"incomplete": json') { } }); }); /** * Description: Verifies that sessionStorage is used as fallback when localStorage operations fail * Success: Storage operations fall back to sessionStorage when localStorage is unavailable or fails */ test('handles sessionStorage fallback when localStorage fails', () => { // Mock localStorage completely failing Object.defineProperty(window, 'localStorage', { value: null, writable: true }); const mockSessionStorage = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn() }; Object.defineProperty(window, 'sessionStorage', { value: mockSessionStorage, writable: true }); const saveWithFallback = (key: string, data: any) => { const dataString = JSON.stringify(data); // Try localStorage first try { if (window.localStorage) { window.localStorage.setItem(key, dataString); return 'localStorage'; } } catch (error) { console.warn('localStorage failed, trying sessionStorage:', error); } // Fallback to sessionStorage try { window.sessionStorage.setItem(key, dataString); return 'sessionStorage'; } catch (error) { console.error('Both localStorage and sessionStorage failed:', error); return 'memory'; // Could implement in-memory storage } }; const result = saveWithFallback('test', { data: 'test' }); expect(result).toBe('sessionStorage'); expect(mockSessionStorage.setItem).toHaveBeenCalledWith('test', '{"data":"test"}'); }); }); describe('Network and Connection Resilience', () => { /** * Description: Verifies that the application adapts behavior based on offline/online network state changes * Success: Offline operations are queued, online operations execute immediately, state transitions are handled smoothly */ test('handles offline/online state changes', () => { let isOnline = true; let queuedOperations: any[] = []; const handleOnlineStatusChange = () => { if (navigator.onLine) { isOnline = true; toast.success('Connection restored'); // Process queued operations while (queuedOperations.length > 0) { const operation = queuedOperations.shift(); console.log('Processing queued operation:', operation); } } else { isOnline = false; toast.error('Connection lost - operations will be queued'); } }; const queueOrExecuteOperation = (operation: any) => { if (isOnline) { console.log('Executing operation immediately:', operation); return true; } else { queuedOperations.push(operation); console.log('Queued operation for later:', operation); return false; } }; // Simulate going offline isOnline = false; handleOnlineStatusChange(); // Queue some operations queueOrExecuteOperation({ type: 'sendMessage', data: 'message1' }); queueOrExecuteOperation({ type: 'sendMessage', data: 'message2' }); // Simulate coming back online isOnline = true; handleOnlineStatusChange(); expect(queuedOperations).toHaveLength(0); expect(toast.success).toHaveBeenCalledWith('Connection restored'); }); /** * Description: Verifies that failed requests are retried with exponential backoff delays * Success: Retry attempts occur with increasing delays (exponential backoff), successful retry ends the sequence */ test('implements exponential backoff for failed requests', async () => { let attemptCount = 0; const maxRetries = 3; const baseDelay = 100; const unreliableOperation = async () => { attemptCount++; if (attemptCount < 3) { throw new Error(`Attempt ${attemptCount} failed`); } return 'Success'; }; const retryWithBackoff = async (operation: () => Promise, retries = maxRetries): Promise => { for (let attempt = 0; attempt <= retries; attempt++) { try { return await operation(); } catch (error) { if (attempt === retries) { throw error; // Final attempt failed } const delay = baseDelay * Math.pow(2, attempt); console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } } }; const result = await retryWithBackoff(unreliableOperation); expect(result).toBe('Success'); expect(attemptCount).toBe(3); }); /** * Description: Verifies that multiple concurrent request failures are handled gracefully without system overload * Success: Concurrent failures are tracked separately, appropriate error handling for each, system remains stable */ test('handles concurrent request failures gracefully', async () => { const failingRequests = [ Promise.reject(new Error('Request 1 failed')), Promise.reject(new Error('Request 2 failed')), Promise.resolve('Request 3 succeeded'), Promise.reject(new Error('Request 4 failed')) ]; const handleConcurrentRequests = async (requests: Promise[]) => { const results = await Promise.allSettled(requests); const successful = results .filter(result => result.status === 'fulfilled') .map(result => (result as PromiseFulfilledResult).value); const failed = results .filter(result => result.status === 'rejected') .map(result => (result as PromiseRejectedResult).reason.message); console.log(`${successful.length} requests succeeded, ${failed.length} failed`); if (failed.length > 0) { console.warn('Failed requests:', failed); } return { successful, failed }; }; const { successful, failed } = await handleConcurrentRequests(failingRequests); expect(successful).toEqual(['Request 3 succeeded']); expect(failed).toEqual([ 'Request 1 failed', 'Request 2 failed', 'Request 4 failed' ]); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/components/Chat.human-interaction.test.tsx ================================================ /** * Tests for human-in-the-loop functionality, OAuth flows, and interaction modals */ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { InteractionModal } from '@/components/Chat/ChatInteractionMessage'; import { isSystemInteractionMessage, isOAuthConsentMessage, extractOAuthUrl } from '@/types/websocket'; // Mock react-hot-toast jest.mock('react-hot-toast', () => ({ __esModule: true, default: { success: jest.fn(), error: jest.fn(), loading: jest.fn(), dismiss: jest.fn() } })); // Mock window.open for OAuth tests const mockWindowOpen = jest.fn(); const mockAddEventListener = jest.fn(); const mockRemoveEventListener = jest.fn(); Object.defineProperty(window, 'open', { value: mockWindowOpen, writable: true }); Object.defineProperty(window, 'addEventListener', { value: mockAddEventListener, writable: true }); Object.defineProperty(window, 'removeEventListener', { value: mockRemoveEventListener, writable: true }); describe('Human-in-the-Loop Functionality', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('Interaction Message Detection', () => { /** * Description: Verifies that isSystemInteractionMessage correctly identifies system interaction message types * Success: Function returns true for system_interaction_message types and false for other message types */ test('isSystemInteractionMessage identifies interaction messages correctly', () => { const interactionMessage = { type: 'system_interaction_message', id: 'interaction-1', conversation_id: 'conv-123', content: { input_type: 'user_confirmation', text: 'Please confirm this action' } }; const responseMessage = { type: 'system_response_message', id: 'response-1', conversation_id: 'conv-123', content: { text: 'Regular response' } }; expect(isSystemInteractionMessage(interactionMessage)).toBe(true); expect(isSystemInteractionMessage(responseMessage)).toBe(false); }); /** * Description: Verifies that isOAuthConsentMessage specifically identifies OAuth consent requests * Success: Function returns true only for OAuth consent interaction types, false for other interactions */ test('isOAuthConsentMessage identifies OAuth consent specifically', () => { const oauthMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.example.com/authorize' } }; const regularInteraction = { type: 'system_interaction_message', content: { input_type: 'user_confirmation', text: 'Please confirm' } }; expect(isOAuthConsentMessage(oauthMessage)).toBe(true); expect(isOAuthConsentMessage(regularInteraction)).toBe(false); }); /** * Description: Verifies that extractOAuthUrl can extract OAuth URLs from different message content locations * Success: URLs are correctly extracted from various message formats and content structures */ test('extractOAuthUrl extracts URLs from various locations', () => { const scenarios = [ { message: { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.primary.com/auth' } }, expected: 'https://oauth.primary.com/auth' }, { message: { type: 'system_interaction_message', content: { input_type: 'oauth_consent', redirect_url: 'https://oauth.redirect.com/auth' } }, expected: 'https://oauth.redirect.com/auth' }, { message: { type: 'system_interaction_message', content: { input_type: 'oauth_consent', text: 'https://oauth.text.com/auth' } }, expected: 'https://oauth.text.com/auth' }, { message: { type: 'system_interaction_message', content: { input_type: 'user_confirmation', text: 'Not OAuth' } }, expected: null } ]; scenarios.forEach(({ message, expected }) => { const result = extractOAuthUrl(message); expect(result).toBe(expected); }); }); }); describe('OAuth Flow Integration', () => { /** * Description: Verifies that OAuth consent messages trigger opening a new browser tab with the correct authorization URL * Success: window.open is called with the extracted OAuth URL and appropriate target parameters */ test('OAuth message opens new tab with correct URL', () => { const handleWebSocketMessage = (message: any) => { if (isSystemInteractionMessage(message) && message.content?.input_type === 'oauth_consent') { const oauthUrl = extractOAuthUrl(message); if (oauthUrl) { window.open(oauthUrl, '_blank'); } } }; const oauthMessage = { type: 'system_interaction_message', conversation_id: 'test-conv', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.provider.com/authorize?state=xyz&client_id=123' } }; handleWebSocketMessage(oauthMessage); expect(mockWindowOpen).toHaveBeenCalledWith( 'https://oauth.provider.com/authorize?state=xyz&client_id=123', '_blank' ); }); /** * Description: Verifies that OAuth flow establishes message event listeners for completion detection * Success: Event listeners are set up to detect OAuth completion messages from popup windows */ test('OAuth flow sets up completion event listener', () => { const handleOAuthConsent = (message: any) => { if (isOAuthConsentMessage(message)) { const oauthUrl = extractOAuthUrl(message); if (oauthUrl) { const popup = window.open(oauthUrl, 'oauth-popup', 'width=600,height=700'); const handleOAuthComplete = (event: MessageEvent) => { if (popup && !popup.closed) popup.close(); window.removeEventListener('message', handleOAuthComplete); }; window.addEventListener('message', handleOAuthComplete); } } }; const oauthMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.example.com/authorize' } }; handleOAuthConsent(oauthMessage); expect(mockWindowOpen).toHaveBeenCalledWith( 'https://oauth.example.com/authorize', 'oauth-popup', 'width=600,height=700' ); expect(mockAddEventListener).toHaveBeenCalledWith( 'message', expect.any(Function) ); }); /** * Description: Verifies that OAuth popup windows are properly closed and cleaned up after completion * Success: Event listeners are removed and popup windows are closed when OAuth flow completes */ test('OAuth popup cleanup on completion', () => { let eventHandler: (event: MessageEvent) => void; mockAddEventListener.mockImplementation((event, handler) => { if (event === 'message') { eventHandler = handler; } }); const mockPopup = { closed: false, close: jest.fn() }; mockWindowOpen.mockReturnValue(mockPopup); const handleOAuthConsent = (message: any) => { if (isOAuthConsentMessage(message)) { const oauthUrl = extractOAuthUrl(message); if (oauthUrl) { const popup = window.open(oauthUrl, 'oauth-popup', 'width=600,height=700'); const handleOAuthComplete = (event: MessageEvent) => { if (popup && !popup.closed) popup.close(); window.removeEventListener('message', handleOAuthComplete); }; window.addEventListener('message', handleOAuthComplete); } } }; const oauthMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.example.com/authorize' } }; handleOAuthConsent(oauthMessage); // Simulate OAuth completion message const completionEvent = new MessageEvent('message', { data: { type: 'oauth_complete', success: true } }); eventHandler(completionEvent); expect(mockPopup.close).toHaveBeenCalled(); expect(mockRemoveEventListener).toHaveBeenCalledWith( 'message', expect.any(Function) ); }); }); describe('Interaction Modal Functionality', () => { /** * Description: Verifies that interaction modals open with the correct data and configuration * Success: Modal displays appropriate interaction content, buttons, and user interface elements */ test('modal opens with correct interaction data', () => { let modalOpen = false; let interactionMessage: any = null; const openModal = (data: any) => { interactionMessage = data; modalOpen = true; }; const handleWebSocketMessage = (message: any) => { if (isSystemInteractionMessage(message) && message.content?.input_type !== 'oauth_consent') { openModal(message); } }; const mockInteractionMessage = { type: 'system_interaction_message', id: 'interaction-123', conversation_id: 'conv-456', thread_id: 'thread-789', parent_id: 'parent-101', content: { input_type: 'user_confirmation', text: 'Please confirm this action before proceeding' } }; handleWebSocketMessage(mockInteractionMessage); expect(modalOpen).toBe(true); expect(interactionMessage).toEqual(mockInteractionMessage); }); /** * Description: Verifies that modal context is preserved when closing and reopening interaction dialogs * Success: Modal state and data remain intact through multiple open/close cycles */ test('modal preserves context through close/reopen cycle', () => { let modalOpen = false; let interactionMessage: any = null; const setModalOpen = (open: boolean) => { modalOpen = open; }; const openModal = (data: any) => { interactionMessage = data; modalOpen = true; }; const interactionData = { type: 'system_interaction_message', content: { input_type: 'user_confirmation', text: 'Please confirm this action' }, thread_id: 'thread-123', parent_id: 'parent-456', conversation_id: 'conv-789' }; // Open modal openModal(interactionData); expect(modalOpen).toBe(true); expect(interactionMessage).toEqual(interactionData); // Close modal setModalOpen(false); expect(modalOpen).toBe(false); // Context should be preserved expect(interactionMessage).toEqual(interactionData); // Reopen modal setModalOpen(true); expect(modalOpen).toBe(true); expect(interactionMessage).toEqual(interactionData); }); /** * Description: Verifies that user interaction responses include proper conversation context for backend processing * Success: Response messages contain conversation ID, user input, and necessary context data */ test('user interaction response includes conversation context', () => { const mockWebSocket = { send: jest.fn() }; const handleUserInteraction = ({ interactionMessage = {}, userResponse = '' }: any) => { const wsMessage = { type: 'user_interaction_message', id: 'new-id-123', thread_id: interactionMessage?.thread_id, parent_id: interactionMessage?.parent_id, content: { messages: [ { role: 'user', content: [ { type: 'text', text: userResponse } ] } ] }, timestamp: new Date().toISOString() }; mockWebSocket.send(JSON.stringify(wsMessage)); }; const interactionMessage = { thread_id: 'thread-abc', parent_id: 'parent-def', conversation_id: 'conv-ghi' }; handleUserInteraction({ interactionMessage, userResponse: 'Approved for processing' }); expect(mockWebSocket.send).toHaveBeenCalledTimes(1); const sentMessage = JSON.parse(mockWebSocket.send.mock.calls[0][0]); expect(sentMessage.type).toBe('user_interaction_message'); expect(sentMessage.thread_id).toBe('thread-abc'); expect(sentMessage.parent_id).toBe('parent-def'); expect(sentMessage.content.messages[0].content[0].text).toBe('Approved for processing'); expect(sentMessage.timestamp).toBeDefined(); }); /** * Description: Verifies that interaction modals can handle different types of user interaction requirements * Success: Different interaction types (forms, confirmations, selections) are displayed and handled correctly */ test('modal handles different interaction types', () => { const interactionTypes = [ { type: 'user_confirmation', text: 'Please confirm this action', expectedButton: 'Confirm' }, { type: 'user_input', text: 'Please provide additional information', expectedButton: 'Submit' }, { type: 'approval_required', text: 'Manager approval required', expectedButton: 'Approve' } ]; interactionTypes.forEach(({ type, text, expectedButton }) => { const message = { type: 'system_interaction_message', content: { input_type: type, text: text } }; // Mock modal behavior based on interaction type const getModalConfig = (interactionMessage: any) => { const inputType = interactionMessage.content?.input_type; switch (inputType) { case 'user_confirmation': return { buttonText: 'Confirm', hasTextInput: false }; case 'user_input': return { buttonText: 'Submit', hasTextInput: true }; case 'approval_required': return { buttonText: 'Approve', hasTextInput: false }; default: return { buttonText: 'OK', hasTextInput: false }; } }; const config = getModalConfig(message); expect(config.buttonText).toBe(expectedButton); }); }); }); describe('Error Handling and Edge Cases', () => { /** * Description: Verifies that malformed interaction messages are handled gracefully without breaking the UI * Success: Invalid interaction messages are ignored or show appropriate error states, application continues functioning */ test('handles malformed interaction messages gracefully', () => { const malformedMessages = [ { type: 'system_interaction_message' }, // Missing content { type: 'system_interaction_message', content: {} }, // Empty content { type: 'system_interaction_message', content: null }, // Null content { type: 'system_interaction_message', content: { input_type: null } }, // Null input_type {} // Completely empty ]; malformedMessages.forEach(message => { expect(() => isSystemInteractionMessage(message)).not.toThrow(); expect(() => isOAuthConsentMessage(message)).not.toThrow(); expect(() => extractOAuthUrl(message)).not.toThrow(); // Should return false/null for malformed messages expect(isOAuthConsentMessage(message)).toBe(false); expect(extractOAuthUrl(message)).toBeNull(); }); }); /** * Description: Verifies that OAuth popup blocking by browsers is handled gracefully with fallback options * Success: Popup blocking is detected, appropriate error messages shown, fallback authentication methods offered */ test('handles OAuth popup blocking gracefully', () => { // Mock popup being blocked (window.open returns null) mockWindowOpen.mockReturnValue(null); const handleOAuthConsent = (message: any) => { if (isOAuthConsentMessage(message)) { const oauthUrl = extractOAuthUrl(message); if (oauthUrl) { const popup = window.open(oauthUrl, '_blank'); if (!popup) { // Handle popup blocked scenario console.warn('Popup blocked - please allow popups for OAuth'); // Could show alternative flow or instructions return false; } return true; } } return false; }; const oauthMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.example.com/authorize' } }; const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); const result = handleOAuthConsent(oauthMessage); expect(result).toBe(false); expect(consoleWarn).toHaveBeenCalledWith('Popup blocked - please allow popups for OAuth'); consoleWarn.mockRestore(); }); /** * Description: Verifies that user interaction responses are handled properly when WebSocket connection is unavailable * Success: Responses are queued or alternative communication methods are used when WebSocket is disconnected */ test('handles missing WebSocket connection for user responses', () => { const handleUserInteraction = ({ interactionMessage = {}, userResponse = '' }: any) => { // webSocketRef.current is null const webSocket = null; if (!webSocket) { console.error('Cannot send user response - WebSocket not connected'); return false; } // Would normally send message here return true; }; const consoleError = jest.spyOn(console, 'error').mockImplementation(); const result = handleUserInteraction({ interactionMessage: { thread_id: 'test' }, userResponse: 'Test response' }); expect(result).toBe(false); expect(consoleError).toHaveBeenCalledWith('Cannot send user response - WebSocket not connected'); consoleError.mockRestore(); }); /** * Description: Verifies that multiple simultaneous interaction messages are handled correctly without conflicts * Success: Concurrent interactions are queued or managed appropriately, no data corruption or UI conflicts occur */ test('handles concurrent interaction messages', () => { let activeInteraction: any = null; const interactionQueue: any[] = []; const handleWebSocketMessage = (message: any) => { if (isSystemInteractionMessage(message) && message.content?.input_type !== 'oauth_consent') { if (activeInteraction) { // Queue additional interactions interactionQueue.push(message); } else { // Handle immediately activeInteraction = message; } } }; const completeInteraction = () => { activeInteraction = null; // Process next in queue if (interactionQueue.length > 0) { activeInteraction = interactionQueue.shift(); } }; // Send multiple interactions const interactions = [ { type: 'system_interaction_message', id: '1', content: { input_type: 'user_confirmation', text: 'First' } }, { type: 'system_interaction_message', id: '2', content: { input_type: 'user_confirmation', text: 'Second' } }, { type: 'system_interaction_message', id: '3', content: { input_type: 'user_confirmation', text: 'Third' } } ]; interactions.forEach(handleWebSocketMessage); // First should be active, others queued expect(activeInteraction.id).toBe('1'); expect(interactionQueue).toHaveLength(2); // Complete first interaction completeInteraction(); expect(activeInteraction.id).toBe('2'); expect(interactionQueue).toHaveLength(1); // Complete second interaction completeInteraction(); expect(activeInteraction.id).toBe('3'); expect(interactionQueue).toHaveLength(0); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/components/Chat.streaming-edge-cases.test.tsx ================================================ /** * Tests for HTTP streaming edge cases and error recovery scenarios */ function normalizeNewlines(s: string): string { return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } function extractSsePayloads(buffer: string): { frames: string[]; rest: string; } { buffer = normalizeNewlines(buffer); const parts = buffer.split(/\n\n/); const rest = parts.pop() ?? ''; const frames: string[] = []; for (const block of parts) { const dataLines = block .split('\n') .filter(line => /^data:\s*/.test(line)) .map(line => line.replace(/^data:\s*/, '').trim()) .filter(line => line.length > 0); if (dataLines.length === 0) continue; const payload = dataLines.join('\n'); if (payload === '[DONE]' || payload === 'DONE') continue; frames.push(payload); } return { frames, rest }; } function splitNdjson(buffer: string): { lines: string[]; rest: string } { buffer = normalizeNewlines(buffer); const parts = buffer.split('\n'); const rest = parts.pop() ?? ''; const lines = parts.map(l => l.trim()).filter(Boolean); return { lines, rest }; } function tryParseJson(s: string): T | null { try { return JSON.parse(s); } catch { return null; } } function parsePossiblyConcatenatedJson(payload: string): any[] { const single = tryParseJson(payload); if (single !== null) return [single]; const objs: any[] = []; let depth = 0, start = -1; for (let i = 0; i < payload.length; i++) { const ch = payload[i]; if (ch === '{') { if (depth === 0) start = i; depth++; } else if (ch === '}') { depth--; if (depth === 0 && start !== -1) { const slice = payload.slice(start, i + 1); const parsed = tryParseJson(slice); if (parsed !== null) objs.push(parsed); start = -1; } } } return objs; } // Mock TextEncoder/TextDecoder for streaming tests global.TextEncoder = jest.fn().mockImplementation(() => ({ encode: jest.fn(text => new Uint8Array(Buffer.from(text, 'utf8'))) })); global.TextDecoder = jest.fn().mockImplementation(() => ({ decode: jest.fn((bytes, options) => { if (bytes instanceof Uint8Array) { return Buffer.from(bytes).toString('utf8'); } return String(bytes); }) })); describe('HTTP Streaming Edge Cases', () => { let encoder: TextEncoder; let decoder: TextDecoder; beforeEach(() => { encoder = new TextEncoder(); decoder = new TextDecoder(); jest.clearAllMocks(); }); describe('SSE Frame Processing - REAL FUNCTION TESTS', () => { /** * Description: Verifies that extractSsePayloads correctly reassembles SSE frames split across multiple network chunks * Success: Incomplete frames are buffered until complete, then extracted in the correct order without data loss */ test('handles incomplete SSE frames gracefully', () => { let buffer = ''; const chunks = [ 'data: {"value": "Hello', // Incomplete JSON ' world"}\n\n', // Completion 'data: [DONE]\n\n' // End marker ]; let allFrames: string[] = []; chunks.forEach(chunk => { buffer += chunk; const { frames, rest } = extractSsePayloads(buffer); allFrames.push(...frames); buffer = rest; }); expect(allFrames).toHaveLength(1); expect(allFrames[0]).toBe('{"value": "Hello world"}'); }); /** * Description: Verifies that extractSsePayloads can process multiple complete SSE events within a single chunk * Success: All complete events are extracted in order, with empty rest buffer when all frames are complete */ test('handles multiple SSE events in single chunk', () => { const multiEventChunk = `data: {"value": "First"}\n\ndata: {"value": "Second"}\n\ndata: {"value": "Third"}\n\n`; const { frames, rest } = extractSsePayloads(multiEventChunk); expect(frames).toHaveLength(3); expect(frames[0]).toBe('{"value": "First"}'); expect(frames[1]).toBe('{"value": "Second"}'); expect(frames[2]).toBe('{"value": "Third"}'); expect(rest).toBe(''); }); /** * Description: Verifies that extractSsePayloads safely ignores malformed SSE lines while preserving valid ones * Success: Valid SSE frames are extracted correctly, malformed lines are filtered out without errors */ test('ignores malformed SSE lines', () => { const malformedChunk = `invalid line without data prefix data: {"value": "valid"} not-data: {"value": "invalid"} data: {"value": "another valid"} `; const { frames, rest } = extractSsePayloads(malformedChunk); expect(frames).toHaveLength(2); expect(frames[0]).toBe('{"value": "valid"}'); expect(frames[1]).toBe('{"value": "another valid"}'); }); /** * Description: Verifies that extractSsePayloads correctly processes SSE DONE markers that signal end of stream * Success: DONE markers are extracted as regular frames, signaling completion of the streaming response */ test('handles DONE markers correctly', () => { const chunkWithDone = `data: {"value": "content"}\n\ndata: [DONE]\n\ndata: {"value": "should be ignored"}\n\n`; const { frames, rest } = extractSsePayloads(chunkWithDone); expect(frames).toHaveLength(2); // content + should be ignored (DONE doesn't filter here) expect(frames[0]).toBe('{"value": "content"}'); }); /** * Description: Verifies that extractSsePayloads preserves incomplete frames in the rest buffer for next processing * Success: Partial frames at end of buffer are returned in rest field, not lost or corrupted */ test('preserves partial frames in rest buffer', () => { const partialChunk = `data: {"value": "complete"}\n\ndata: {"value": "incomp`; const { frames, rest } = extractSsePayloads(partialChunk); expect(frames).toHaveLength(1); expect(frames[0]).toBe('{"value": "complete"}'); expect(rest).toBe('data: {"value": "incomp'); }); }); describe('NDJSON Processing', () => { /** * Description: Verifies that splitNdjson correctly separates newline-delimited JSON objects * Success: Each JSON object on a separate line is extracted individually with partial lines preserved in rest */ test('splits newline-delimited JSON correctly', () => { const ndjsonData = `{"value": "line1"}\n{"value": "line2"}\n{"value": "partial`; const { lines, rest } = splitNdjson(ndjsonData); expect(lines).toHaveLength(2); expect(lines[0]).toBe('{"value": "line1"}'); expect(lines[1]).toBe('{"value": "line2"}'); expect(rest).toBe('{"value": "partial'); }); /** * Description: Verifies that splitNdjson ignores empty lines and whitespace between JSON objects * Success: Empty lines and whitespace are filtered out, only valid JSON objects are returned */ test('handles empty lines and whitespace', () => { const ndjsonWithEmpty = `{"value": "line1"}\n\n \n{"value": "line2"}\n\t\n`; const { lines, rest } = splitNdjson(ndjsonWithEmpty); expect(lines).toHaveLength(2); expect(lines[0]).toBe('{"value": "line1"}'); expect(lines[1]).toBe('{"value": "line2"}'); }); /** * Description: Verifies that splitNdjson handles different line ending formats (\r\n, \r, \n) * Success: All line ending formats are normalized and JSON objects are correctly separated */ test('normalizes different line endings', () => { const mixedLineEndings = `{"value": "line1"}\r\n{"value": "line2"}\r{"value": "line3"}\n`; const { lines, rest } = splitNdjson(mixedLineEndings); expect(lines).toHaveLength(3); expect(lines[0]).toBe('{"value": "line1"}'); expect(lines[1]).toBe('{"value": "line2"}'); expect(lines[2]).toBe('{"value": "line3"}'); }); }); describe('JSON Parsing Edge Cases', () => { /** * Description: Verifies that parsePossiblyConcatenatedJson correctly processes single valid JSON objects * Success: Single JSON object is parsed and returned in array format */ test('parsePossiblyConcatenatedJson handles single valid JSON', () => { const singleJson = '{"value": "test"}'; const results = parsePossiblyConcatenatedJson(singleJson); expect(results).toHaveLength(1); expect(results[0]).toEqual({ value: "test" }); }); /** * Description: Verifies that parsePossiblyConcatenatedJson can parse multiple JSON objects concatenated together * Success: Multiple concatenated JSON objects are separated and parsed into individual array elements */ test('parsePossiblyConcatenatedJson handles concatenated objects', () => { const concatenatedJson = '{"value": "first"}{"value": "second"}{"value": "third"}'; const results = parsePossiblyConcatenatedJson(concatenatedJson); expect(results).toHaveLength(3); expect(results[0]).toEqual({ value: "first" }); expect(results[1]).toEqual({ value: "second" }); expect(results[2]).toEqual({ value: "third" }); }); /** * Description: Verifies that parsePossiblyConcatenatedJson correctly handles nested JSON objects * Success: Complex nested objects are parsed correctly while maintaining their structure */ test('parsePossiblyConcatenatedJson handles nested objects', () => { const nestedJson = '{"data": {"nested": "value"}}{"simple": "value"}'; const results = parsePossiblyConcatenatedJson(nestedJson); expect(results).toHaveLength(2); expect(results[0]).toEqual({ data: { nested: "value" } }); expect(results[1]).toEqual({ simple: "value" }); }); /** * Description: Verifies that parsePossiblyConcatenatedJson safely handles malformed JSON without throwing errors * Success: Malformed JSON is ignored, valid portions are extracted, function doesn't crash */ test('parsePossiblyConcatenatedJson handles malformed JSON gracefully', () => { const malformedJson = '{"valid": "object"}{"malformed": invalid}{"another": "valid"}'; const results = parsePossiblyConcatenatedJson(malformedJson); // Should extract valid objects and ignore malformed ones expect(results).toHaveLength(2); expect(results[0]).toEqual({ valid: "object" }); expect(results[1]).toEqual({ another: "valid" }); }); /** * Description: Verifies that parsePossiblyConcatenatedJson returns empty array for completely invalid input * Success: Invalid or non-string input returns empty array without throwing exceptions */ test('parsePossiblyConcatenatedJson returns empty array for invalid input', () => { const invalidInputs = ['', 'not json at all', '}{invalid', '{incomplete']; invalidInputs.forEach(input => { const results = parsePossiblyConcatenatedJson(input); expect(results).toHaveLength(0); }); }); }); describe('Streaming Performance and Memory', () => { /** * Description: Verifies that rapid processing of multiple chunks maintains data integrity * Success: All chunks are processed correctly in sequence without losing or corrupting data */ test('handles rapid chunk succession without data loss', () => { const rapidChunks = Array.from({ length: 100 }, (_, i) => `data: {"value": "chunk${i}"}\n\n` ); let buffer = ''; let allFrames: string[] = []; rapidChunks.forEach(chunk => { buffer += chunk; const { frames, rest } = extractSsePayloads(buffer); allFrames.push(...frames); buffer = rest; }); // Should have received all chunks expect(allFrames).toHaveLength(100); expect(allFrames[0]).toBe('{"value": "chunk0"}'); expect(allFrames[99]).toBe('{"value": "chunk99"}'); }); /** * Description: Verifies that large content chunks are processed efficiently without performance degradation * Success: Large chunks are processed correctly with reasonable performance characteristics */ test('handles large individual chunks efficiently', () => { const largeContent = 'x'.repeat(10000); // 10KB content const largeChunk = `data: {"value": "${largeContent}"}\n\n`; const { frames, rest } = extractSsePayloads(largeChunk); expect(frames).toHaveLength(1); expect(JSON.parse(frames[0]).value).toBe(largeContent); expect(rest).toBe(''); }); /** * Description: Verifies that buffer management doesn't cause memory leaks with long-running operations * Success: Buffers are properly cleaned up and don't accumulate excessive memory usage */ test('buffer management prevents memory leaks', () => { let buffer = ''; const chunks = Array.from({ length: 1000 }, (_, i) => `data: {"chunk": ${i}}\n\n` ); chunks.forEach(chunk => { buffer += chunk; const { frames, rest } = extractSsePayloads(buffer); buffer = rest; // Critical: update buffer to prevent memory accumulation }); // Buffer should not accumulate indefinitely expect(buffer.length).toBeLessThan(1000); }); }); describe('Intermediate Step Tag Processing', () => { /** * Description: Verifies that intermediate step tag processing recovers gracefully from malformed tags * Success: Malformed tags are ignored or corrected, valid tags continue to be processed correctly */ test('recovers from malformed intermediate step tags', () => { const chunksWithMalformed = [ 'data: {"value": "Response"}\n\n', '{"invalid": json}', // Malformed JSON '{"id": "step-1", "type": "system_intermediate"}', // Valid 'data: [DONE]\n\n' ]; const validSteps: string[] = []; const responses: string[] = []; chunksWithMalformed.forEach(chunk => { // Extract SSE data if (chunk.includes('data: ')) { const { frames } = extractSsePayloads(chunk); responses.push(...frames); } // Extract intermediate steps const stepMatches = chunk.match(/([\s\S]*?)<\/intermediatestep>/g) || []; stepMatches.forEach(match => { try { const jsonString = match .replace('', '') .replace('', '') .trim(); const parsed = JSON.parse(jsonString); if (parsed.type === 'system_intermediate') { validSteps.push(jsonString); } } catch { // Ignore malformed steps } }); }); // Should contain valid response and valid step, ignore malformed expect(responses).toContain('{"value": "Response"}'); expect(validSteps).toHaveLength(1); expect(validSteps[0]).toContain('"id": "step-1"'); }); /** * Description: Verifies that incomplete intermediate step tags are handled without breaking processing * Success: Incomplete tags are buffered or ignored appropriately, processing continues for complete tags */ test('handles incomplete intermediate step tags', () => { const incompleteChunks = [ '{"id": "step-1",', // Incomplete tag ' "type": "system_intermediate"}', // Completion 'data: {"value": "response"}\n\n' ]; let buffer = ''; let partialStepBuffer = ''; const completedSteps: string[] = []; incompleteChunks.forEach(chunk => { // Handle potential partial intermediate step if (chunk.includes('') || partialStepBuffer) { partialStepBuffer += chunk; // Check for complete tags const stepMatches = partialStepBuffer.match(/([\s\S]*?)<\/intermediatestep>/g) || []; stepMatches.forEach(match => { try { const jsonString = match .replace('', '') .replace('', '') .trim(); const parsed = JSON.parse(jsonString); completedSteps.push(jsonString); // Remove processed step from buffer partialStepBuffer = partialStepBuffer.replace(match, ''); } catch { // Keep in buffer for next chunk } }); } }); expect(completedSteps).toHaveLength(1); expect(completedSteps[0]).toContain('"id": "step-1"'); }); /** * Description: Verifies that interleaved intermediate steps and responses maintain correct chronological order * Success: Steps and responses are processed in the exact order they were received in the stream */ test('preserves order of interleaved steps and responses', () => { const interleavedChunks = [ 'data: {"value": "Start"}\n\n', '{"id": "step-1", "type": "system_intermediate"}', 'data: {"value": " middle"}\n\n', '{"id": "step-2", "type": "system_intermediate"}', 'data: {"value": " end"}\n\n' ]; const orderedItems: { type: 'response' | 'step', content: string, order: number }[] = []; let order = 0; interleavedChunks.forEach(chunk => { // Process responses if (chunk.includes('data: ')) { const { frames } = extractSsePayloads(chunk); frames.forEach(frame => { if (!frame.includes('[DONE]')) { orderedItems.push({ type: 'response', content: frame, order: order++ }); } }); } // Process steps const stepMatches = chunk.match(/([\s\S]*?)<\/intermediatestep>/g) || []; stepMatches.forEach(match => { const jsonString = match .replace('', '') .replace('', '') .trim(); orderedItems.push({ type: 'step', content: jsonString, order: order++ }); }); }); expect(orderedItems).toHaveLength(5); expect(orderedItems[0].type).toBe('response'); expect(orderedItems[1].type).toBe('step'); expect(orderedItems[2].type).toBe('response'); expect(orderedItems[3].type).toBe('step'); expect(orderedItems[4].type).toBe('response'); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/components/Chat.ui-behavior.test.tsx ================================================ /** * Tests for UI behavior, auto-scroll functionality, and user interaction patterns */ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { throttle } from '@/utils/data/throttle'; // Mock intersection observer for auto-scroll tests const mockIntersectionObserver = jest.fn(); mockIntersectionObserver.mockReturnValue({ observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn(), }); window.IntersectionObserver = mockIntersectionObserver; // Mock requestAnimationFrame global.requestAnimationFrame = jest.fn(cb => setTimeout(cb, 16)); describe('Auto-scroll and UI Behavior', () => { let mockScrollIntoView: jest.Mock; let mockChatContainer: HTMLElement; let messagesEndRef: { current: HTMLElement | null }; let chatContainerRef: { current: HTMLElement | null }; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); // Enable fake timers for each test mockScrollIntoView = jest.fn(); mockChatContainer = document.createElement('div'); // Mock scroll properties Object.defineProperties(mockChatContainer, { scrollTop: { value: 0, writable: true }, scrollHeight: { value: 1000, writable: true }, clientHeight: { value: 500, writable: true } }); messagesEndRef = { current: { scrollIntoView: mockScrollIntoView } as any }; chatContainerRef = { current: mockChatContainer }; }); afterEach(() => { jest.useRealTimers(); // Clean up timers after each test }); describe('Auto-scroll During Streaming', () => { /** * Description: Verifies that the chat interface automatically scrolls to bottom during message streaming * Success: scrollIntoView is called on messagesEndRef when auto-scroll is enabled during streaming */ test('auto-scrolls during message streaming', () => { let autoScrollEnabled = true; let messageIsStreaming = true; const scrollDown = () => { if (autoScrollEnabled) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); } }; // Simulate streaming state expect(messageIsStreaming).toBe(true); expect(autoScrollEnabled).toBe(true); // Trigger scroll scrollDown(); expect(mockScrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }); }); /** * Description: Verifies that auto-scroll is automatically enabled when message streaming begins * Success: Auto-scroll state is set to true and scrolling behavior is activated when streaming starts */ test('enables auto-scroll when streaming starts', () => { let autoScrollEnabled = false; let showScrollDownButton = true; let messageIsStreaming = false; const handleStreamingStateChange = (streaming: boolean) => { if (streaming) { autoScrollEnabled = true; showScrollDownButton = false; messageIsStreaming = true; } }; // Start streaming handleStreamingStateChange(true); expect(autoScrollEnabled).toBe(true); expect(showScrollDownButton).toBe(false); expect(messageIsStreaming).toBe(true); }); /** * Description: Verifies that auto-scroll is disabled when user manually scrolls up from bottom * Success: Auto-scroll state becomes false when user scroll position moves away from bottom */ test('stops auto-scroll when user scrolls up manually', () => { let autoScrollEnabled = true; let showScrollDownButton = false; let messageIsStreaming = true; let lastScrollTop = 400; const handleScroll = () => { if (!chatContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current; const isScrollingUp = scrollTop < lastScrollTop; const isAtBottom = scrollHeight - scrollTop - clientHeight < 20; // Disable auto-scroll if user scrolls up during streaming if (isScrollingUp && autoScrollEnabled && messageIsStreaming) { autoScrollEnabled = false; showScrollDownButton = true; } // Re-enable auto-scroll if user scrolls to bottom if (isAtBottom && !autoScrollEnabled) { autoScrollEnabled = true; showScrollDownButton = false; } lastScrollTop = scrollTop; }; // Simulate user scrolling up if (chatContainerRef.current) { chatContainerRef.current.scrollTop = 200; // Scroll up from 400 to 200 } handleScroll(); expect(autoScrollEnabled).toBe(false); expect(showScrollDownButton).toBe(true); }); /** * Description: Verifies that auto-scroll is re-enabled when user manually scrolls back to bottom * Success: Auto-scroll state becomes true when scroll position returns to bottom of chat */ test('re-enables auto-scroll when user scrolls to bottom', () => { let autoScrollEnabled = false; let showScrollDownButton = true; let lastScrollTop = 200; const handleScroll = () => { if (!chatContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current; const isAtBottom = scrollHeight - scrollTop - clientHeight < 20; if (isAtBottom && !autoScrollEnabled) { autoScrollEnabled = true; showScrollDownButton = false; } lastScrollTop = scrollTop; }; // Simulate user scrolling to bottom if (chatContainerRef.current) { chatContainerRef.current.scrollTop = 485; // Close to bottom (scrollHeight - clientHeight - tolerance) } handleScroll(); expect(autoScrollEnabled).toBe(true); expect(showScrollDownButton).toBe(false); }); /** * Description: Verifies that clicking the scroll down button smoothly scrolls chat to bottom * Success: scrollIntoView is called with smooth behavior when scroll down button is clicked */ test('handles scroll down button click', () => { let autoScrollEnabled = false; const handleScrollDown = () => { chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: 'smooth' }); autoScrollEnabled = true; }; const mockScrollTo = jest.fn(); if (chatContainerRef.current) { chatContainerRef.current.scrollTo = mockScrollTo; } handleScrollDown(); expect(mockScrollTo).toHaveBeenCalledWith({ top: 1000, // scrollHeight behavior: 'smooth' }); expect(autoScrollEnabled).toBe(true); }); }); describe('User-Initiated Scroll Detection', () => { /** * Description: Verifies that the system can differentiate between user-initiated and programmatic scrolling * Success: User scrolling affects auto-scroll state, programmatic scrolling does not interfere with user preferences */ test('distinguishes between user and programmatic scrolling', () => { let isUserInitiatedScroll = false; let scrollTimeout: NodeJS.Timeout | null = null; const handleUserInput = () => { isUserInitiatedScroll = true; if (scrollTimeout) { clearTimeout(scrollTimeout); } scrollTimeout = setTimeout(() => { isUserInitiatedScroll = false; }, 200); }; const handleScroll = () => { if (!isUserInitiatedScroll) return; // Ignore programmatic scrolls // Handle user scroll logic here console.log('User scrolled'); }; const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); // Simulate user interaction handleUserInput(); expect(isUserInitiatedScroll).toBe(true); // Simulate scroll event handleScroll(); expect(consoleSpy).toHaveBeenCalledWith('User scrolled'); // Fast-forward past timeout jest.advanceTimersByTime(250); expect(isUserInitiatedScroll).toBe(false); // Programmatic scroll should be ignored handleScroll(); expect(consoleSpy).toHaveBeenCalledTimes(1); // Still only called once consoleSpy.mockRestore(); }); /** * Description: Verifies that wheel and touch events are properly detected for scroll state management * Success: Both wheel and touch events trigger appropriate scroll state updates and event listeners */ test('handles wheel and touch events for scroll detection', () => { let userInteractionDetected = false; const handleUserInput = () => { userInteractionDetected = true; }; // Simulate adding event listeners const mockAddEventListener = jest.fn(); if (chatContainerRef.current) { chatContainerRef.current.addEventListener = mockAddEventListener; } // Setup event listeners (simulating useEffect) if (chatContainerRef.current) { chatContainerRef.current.addEventListener('wheel', handleUserInput, { passive: true }); chatContainerRef.current.addEventListener('touchmove', handleUserInput, { passive: true }); } expect(mockAddEventListener).toHaveBeenCalledWith('wheel', handleUserInput, { passive: true }); expect(mockAddEventListener).toHaveBeenCalledWith('touchmove', handleUserInput, { passive: true }); // Simulate user interaction handleUserInput(); expect(userInteractionDetected).toBe(true); }); /** * Description: Verifies that scroll event listeners are properly removed when component unmounts * Success: removeEventListener is called for all registered scroll events to prevent memory leaks */ test('cleans up event listeners on unmount', () => { const mockRemoveEventListener = jest.fn(); if (chatContainerRef.current) { chatContainerRef.current.removeEventListener = mockRemoveEventListener; } const cleanup = () => { if (chatContainerRef.current) { chatContainerRef.current.removeEventListener('wheel', jest.fn()); chatContainerRef.current.removeEventListener('touchmove', jest.fn()); } }; cleanup(); expect(mockRemoveEventListener).toHaveBeenCalledTimes(2); }); }); describe('Throttled Scroll Behavior - REAL FUNCTION TESTS', () => { /** * Description: Verifies that the throttle function limits call frequency to prevent performance issues * Success: First call executes immediately, subsequent calls within time window are ignored, calls after window execute normally */ test('throttles scroll events to prevent performance issues', () => { let scrollCallCount = 0; const scrollDown = () => { scrollCallCount++; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }; const throttledScrollDown = throttle(scrollDown, 250); // Call multiple times rapidly throttledScrollDown(); throttledScrollDown(); throttledScrollDown(); throttledScrollDown(); throttledScrollDown(); // Should only execute once immediately expect(scrollCallCount).toBe(1); // Fast-forward past throttle period using fake timers jest.advanceTimersByTime(300); // Call again after throttle period throttledScrollDown(); expect(scrollCallCount).toBe(2); }); /** * Description: Verifies that throttle preserves the most recent function call when multiple calls occur rapidly * Success: When throttling occurs, the latest function call parameters are preserved and executed */ test('throttle preserves latest call', () => { let lastValue = ''; const updateValue = (value: string) => { lastValue = value; }; const throttledUpdate = throttle(updateValue, 100); // Make rapid calls with different values throttledUpdate('first'); throttledUpdate('second'); throttledUpdate('third'); throttledUpdate('final'); // Should execute immediately with first value expect(lastValue).toBe('first'); // Fast-forward past throttle period jest.advanceTimersByTime(150); // Should execute with the latest value expect(lastValue).toBe('final'); }); }); describe('Intersection Observer Integration', () => { /** * Description: Verifies that intersection observer is properly configured for auto-scroll functionality * Success: IntersectionObserver is created and observes the messages end element for visibility changes */ test('sets up intersection observer for auto-scroll', () => { const mockObserver = { observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn() }; mockIntersectionObserver.mockImplementation((callback) => { // Simulate intersection setTimeout(() => { callback([{ isIntersecting: true }]); }, 0); return mockObserver; }); let autoScrollEnabled = true; let messageIsStreaming = true; // Setup observer (simulating useEffect) const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && autoScrollEnabled && messageIsStreaming) { requestAnimationFrame(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }); } }, { root: null, threshold: 0.5 } ); if (messagesEndRef.current) { observer.observe(messagesEndRef.current); } expect(mockObserver.observe).toHaveBeenCalledWith(messagesEndRef.current); }); /** * Description: Verifies that intersection observer is properly disconnected when component unmounts * Success: IntersectionObserver.disconnect is called to prevent memory leaks and orphaned observers */ test('cleans up intersection observer on unmount', () => { const mockObserver = { observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn() }; mockIntersectionObserver.mockReturnValue(mockObserver); const observer = new IntersectionObserver(() => {}); if (messagesEndRef.current) { observer.observe(messagesEndRef.current); } // Simulate cleanup if (messagesEndRef.current) { observer.unobserve(messagesEndRef.current); } expect(mockObserver.unobserve).toHaveBeenCalledWith(messagesEndRef.current); }); }); describe('Scroll State Management', () => { /** * Description: Verifies that scroll state is preserved during component re-renders * Success: Scroll position and auto-scroll state remain consistent after component updates */ test('maintains scroll state across re-renders', () => { let scrollState = { autoScrollEnabled: true, showScrollDownButton: false, lastScrollTop: 0 }; const updateScrollState = (updates: Partial) => { scrollState = { ...scrollState, ...updates }; }; // Simulate state changes updateScrollState({ autoScrollEnabled: false, showScrollDownButton: true }); expect(scrollState.autoScrollEnabled).toBe(false); expect(scrollState.showScrollDownButton).toBe(true); updateScrollState({ lastScrollTop: 300 }); expect(scrollState.lastScrollTop).toBe(300); expect(scrollState.autoScrollEnabled).toBe(false); // Should preserve other state }); /** * Description: Verifies that scroll position calculations handle edge cases correctly * Success: Edge cases like content shorter than container or exact bottom position are handled properly */ test('handles scroll position edge cases', () => { const testCases = [ { scrollTop: 0, scrollHeight: 1000, clientHeight: 500, expectedAtBottom: false }, { scrollTop: 485, scrollHeight: 1000, clientHeight: 500, expectedAtBottom: true }, // Within 15px tolerance (1000-485-500 = 15 < 20) { scrollTop: 500, scrollHeight: 1000, clientHeight: 500, expectedAtBottom: true }, // Exact bottom (1000-500-500 = 0 < 20) { scrollTop: 450, scrollHeight: 1000, clientHeight: 500, expectedAtBottom: false }, // Outside tolerance (1000-450-500 = 50 >= 20) { scrollTop: 0, scrollHeight: 400, clientHeight: 500, expectedAtBottom: true }, // Content shorter than container (400-0-500 = -100 < 20) ]; testCases.forEach(({ scrollTop, scrollHeight, clientHeight, expectedAtBottom }) => { const isAtBottom = scrollHeight - scrollTop - clientHeight < 20; expect(isAtBottom).toBe(expectedAtBottom); }); }); /** * Description: Verifies that concurrent scroll state updates don't cause race conditions * Success: Scroll state updates are processed sequentially without conflicts or data loss */ test('prevents scroll state race conditions', () => { let scrollState = { processing: false, pendingUpdate: null as any }; const canProcessUpdate = () => { return !scrollState.processing; }; const startProcessing = () => { scrollState.processing = true; }; const finishProcessing = () => { scrollState.processing = false; }; // Test initial state expect(canProcessUpdate()).toBe(true); // Start processing startProcessing(); expect(canProcessUpdate()).toBe(false); // Can't process while already processing expect(scrollState.processing).toBe(true); // Finish processing finishProcessing(); expect(canProcessUpdate()).toBe(true); }); }); describe('Focus Management', () => { /** * Description: Verifies that textarea receives focus when messages end element becomes visible * Success: Textarea focus method is called when intersection observer detects messages end is intersecting */ test('focuses textarea when messages end is intersecting', () => { let textareaRef = { current: { focus: jest.fn() } as any }; let observerCallback: ((entries: any[]) => void) | null = null; const mockObserver = { observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn() }; mockIntersectionObserver.mockImplementation((callback) => { observerCallback = callback; return mockObserver; }); // Setup observer const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { textareaRef.current?.focus(); } }, { root: null, threshold: 0.5 } ); if (messagesEndRef.current) { observer.observe(messagesEndRef.current); } // Simulate intersection if (observerCallback) { observerCallback([{ isIntersecting: true }]); } expect(textareaRef.current.focus).toHaveBeenCalled(); }); /** * Description: Verifies that focus state is maintained properly during scroll events * Success: Focus state remains consistent and doesn't interfere with scroll behavior or get lost during scrolling */ test('maintains focus state during scroll events', () => { let textareaFocused = false; const handleFocus = () => { textareaFocused = true; }; const handleBlur = () => { textareaFocused = false; }; const handleScroll = () => { // Focus should not be affected by scroll events // unless specifically managed }; handleFocus(); expect(textareaFocused).toBe(true); handleScroll(); expect(textareaFocused).toBe(true); // Should remain focused handleBlur(); expect(textareaFocused).toBe(false); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/components/Chat.websocket-reliability.test.tsx ================================================ /** * Tests for WebSocket connection reliability, message ordering, and session management */ import MockWebSocket from '@/__mocks__/websocket'; import { SESSION_COOKIE_NAME } from '@/constants/constants'; import { validateWebSocketMessageWithConversationId, isSystemResponseMessage, isSystemIntermediateMessage, processSystemResponseMessage } from '@/types/websocket'; import toast from 'react-hot-toast'; // Mock react-hot-toast jest.mock('react-hot-toast', () => ({ __esModule: true, default: { loading: jest.fn(), success: jest.fn(), error: jest.fn(), dismiss: jest.fn() } })); // Mock timers for connection timeout tests jest.useFakeTimers(); describe('WebSocket Connection Reliability', () => { beforeEach(() => { MockWebSocket.lastInstance = null; jest.clearAllMocks(); jest.clearAllTimers(); }); afterEach(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); jest.useFakeTimers(); }); describe('Connection Management', () => { /** * Description: Verifies that WebSocket connection timeouts during handshake are handled with appropriate user feedback * Success: Loading toast is displayed during connection attempts, success toast shown on completion */ test('handles connection timeout during handshake', async () => { let resolveConnection: (value: boolean) => void; const connectionPromise = new Promise(resolve => { resolveConnection = resolve; }); // Simulate slow connecting WebSocket const mockConnectWebSocket = async (retryCount = 0) => { const maxRetries = 3; const retryDelay = 1000; if (retryCount >= maxRetries) { resolveConnection(false); return false; } return new Promise(resolve => { const ws = new MockWebSocket('ws://slow-server.com/websocket'); toast.loading('WebSocket is not connected, trying to connect...', { id: 'websocketLoadingToastId' }); // Simulate connection taking longer than expected setTimeout(() => { ws.readyState = MockWebSocket.OPEN; if (ws.onopen) ws.onopen(new Event('open')); toast.success('Connected to server'); resolveConnection(true); resolve(true); }, 5000); // 5 second delay ws.onclose = async () => { if (retryCount < maxRetries) { await new Promise(res => setTimeout(res, retryDelay)); const success = await mockConnectWebSocket(retryCount + 1); resolve(success); } else { toast.error('WebSocket connection failed.'); resolveConnection(false); resolve(false); } }; }); }; // Start connection attempt const connectionAttempt = mockConnectWebSocket(); // Advance timers by 3 seconds (less than connection time) jest.advanceTimersByTime(3000); // Should still be attempting connection expect(toast.loading).toHaveBeenCalledWith( 'WebSocket is not connected, trying to connect...', { id: 'websocketLoadingToastId' } ); // Complete the connection jest.advanceTimersByTime(2000); const result = await connectionAttempt; expect(result).toBe(true); expect(toast.success).toHaveBeenCalledWith('Connected to server'); }); /** * Description: Verifies that WebSocket connection retries implement exponential backoff delays * Success: Connection attempts are retried with increasing delays until successful connection is established */ test('retry mechanism with exponential backoff', () => { const baseDelay = 1000; const maxRetries = 3; const calculatedDelays: number[] = []; // Simulate the retry delay calculation logic for (let attempt = 0; attempt < maxRetries; attempt++) { const delay = baseDelay * Math.pow(2, attempt); calculatedDelays.push(delay); } // Verify exponential backoff pattern expect(calculatedDelays).toEqual([1000, 2000, 4000]); expect(calculatedDelays[0]).toBe(1000); // First retry: 1000ms expect(calculatedDelays[1]).toBe(2000); // Second retry: 2000ms expect(calculatedDelays[2]).toBe(4000); // Third retry: 4000ms }); /** * Description: Verifies that WebSocket connections are properly closed when component unmounts * Success: WebSocket connection is closed and cleanup procedures are executed to prevent memory leaks */ test('connection cleanup on component unmount', () => { const ws = new MockWebSocket('ws://test-server.com/websocket'); const mockClose = jest.spyOn(ws, 'close'); // Simulate component unmount cleanup const cleanup = () => { if (ws && ws.readyState === MockWebSocket.OPEN) { ws.close(); } }; ws.readyState = MockWebSocket.OPEN; cleanup(); expect(mockClose).toHaveBeenCalled(); }); }); describe('Session Cookie Management', () => { /** * Description: Verifies that session cookies can be extracted from various cookie string formats * Success: Session cookies are correctly parsed from different cookie formats and encoding styles */ test('session cookie extraction works with various cookie formats', () => { const cookieScenarios = [ { cookie: `${SESSION_COOKIE_NAME}=simple-session`, expected: 'simple-session' }, { cookie: `other=value; ${SESSION_COOKIE_NAME}=session-with-prefix; more=data`, expected: 'session-with-prefix' }, { cookie: `${SESSION_COOKIE_NAME}=session%20with%20encoding`, expected: 'session%20with%20encoding' }, { cookie: `prefix_${SESSION_COOKIE_NAME}=wrong; ${SESSION_COOKIE_NAME}=correct`, expected: 'correct' }, { cookie: `${SESSION_COOKIE_NAME}=value_with_equals=sign`, expected: 'value_with_equals=sign' } ]; const getCookie = (name: string, cookieString: string) => { const value = `; ${cookieString}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift(); return null; }; cookieScenarios.forEach(({ cookie, expected }) => { const extracted = getCookie(SESSION_COOKIE_NAME, cookie); expect(extracted).toBe(expected); expect(extracted).not.toContain('wrong'); }); }); /** * Description: Verifies that missing or malformed session cookies are handled gracefully without errors * Success: Connection continues with fallback authentication, no exceptions thrown for invalid cookies */ test('handles missing or malformed cookies gracefully', () => { const invalidCookieScenarios = [ '', 'other=value; different=cookie', `other_${SESSION_COOKIE_NAME}=not-exact-match`, 'malformed cookie string without equals', `${SESSION_COOKIE_NAME}=`, // Empty value `${SESSION_COOKIE_NAME}` // No equals sign ]; const getCookie = (name: string, cookieString: string) => { const value = `; ${cookieString}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift(); return null; }; invalidCookieScenarios.forEach(cookie => { const extracted = getCookie(SESSION_COOKIE_NAME, cookie); // Should either be null or empty string, but not crash expect(typeof extracted === 'string' || extracted === null).toBe(true); }); }); /** * Description: Verifies that WebSocket URLs are correctly constructed with session cookie parameters * Success: Session cookies are properly encoded and included in WebSocket connection URL */ test('WebSocket URL construction with session cookie', () => { const sessionId = 'test-session-123'; const baseUrls = [ 'ws://example.com/websocket', 'wss://secure.example.com/websocket', 'ws://localhost:8000/websocket', 'ws://example.com/websocket?existing=param' ]; baseUrls.forEach(baseUrl => { const separator = baseUrl.includes('?') ? '&' : '?'; const finalUrl = `${baseUrl}${separator}session=${encodeURIComponent(sessionId)}`; const ws = new MockWebSocket(finalUrl); expect(ws.url).toContain('session='); expect(ws.url).toContain(encodeURIComponent(sessionId)); // Verify URL is properly formed expect(() => new URL(ws.url.replace('ws:', 'http:').replace('wss:', 'https:'))).not.toThrow(); }); }); /** * Description: Verifies that cross-origin WebSocket connections include session data in URL parameters * Success: Session information is correctly included in URL for cross-origin authentication */ test('cross-origin connection includes session in URL', () => { const sessionId = 'cross-origin-session'; const cookieString = `${SESSION_COOKIE_NAME}=${sessionId}`; // Mock document.cookie Object.defineProperty(document, 'cookie', { value: cookieString, writable: true }); const getCookie = (name: string) => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift(); return null; }; const sessionCookie = getCookie(SESSION_COOKIE_NAME); let wsUrl = 'wss://external-server.com/websocket'; // Determine if this is cross-origin (it is, since we're testing from localhost) const wsUrlObj = new URL(wsUrl); const isCrossOrigin = wsUrlObj.origin !== window.location.origin; // Always add session cookie for cross-origin if (sessionCookie && isCrossOrigin) { const separator = wsUrl.includes('?') ? '&' : '?'; wsUrl += `${separator}session=${encodeURIComponent(sessionCookie)}`; } expect(wsUrl).toContain(`session=${encodeURIComponent(sessionId)}`); expect(isCrossOrigin).toBe(true); }); }); describe('Message Ordering and Processing', () => { /** * Description: Verifies that message ordering is preserved when receiving rapid WebSocket messages * Success: Messages are processed and displayed in the exact order they were received */ test('maintains message order during rapid WebSocket messages', () => { const messages: any[] = []; const conversation = { id: 'test-conv', messages: [] }; // Create rapid sequence of messages const rapidMessages = Array.from({ length: 50 }, (_, i) => ({ type: 'system_response_message', status: 'in_progress', conversation_id: 'test-conv', id: `msg-${i}`, content: { text: `chunk${i}` } })); // Mock message processing function const processMessage = (message: any, currentMessages: any[]) => { if (!isSystemResponseMessage(message)) return currentMessages; const lastMessage = currentMessages[currentMessages.length - 1]; if (lastMessage && lastMessage.role === 'assistant') { // Append to existing message return currentMessages.map((m, idx) => idx === currentMessages.length - 1 ? { ...m, content: (m.content || '') + message.content.text } : m ); } else { // Create new assistant message return [...currentMessages, { role: 'assistant', content: message.content.text, id: message.id }]; } }; // Process messages sequentially let currentMessages = conversation.messages; rapidMessages.forEach(msg => { currentMessages = processMessage(msg, currentMessages); }); // Verify content built correctly in order expect(currentMessages).toHaveLength(1); const finalContent = currentMessages[0].content; expect(finalContent).toContain('chunk0'); expect(finalContent).toContain('chunk49'); // Verify all chunks are present and in order for (let i = 0; i < 50; i++) { expect(finalContent).toContain(`chunk${i}`); } }); /** * Description: Verifies that out-of-order WebSocket messages are handled gracefully without corruption * Success: Messages are reordered correctly or processed independently without breaking conversation flow */ test('handles out-of-order message IDs gracefully', () => { const conversation = { id: 'test-conv', messages: [] }; // Messages arrive out of order const outOfOrderMessages = [ { type: 'system_response_message', status: 'in_progress', conversation_id: 'test-conv', id: 'msg-3', content: { text: 'third' } }, { type: 'system_response_message', status: 'in_progress', conversation_id: 'test-conv', id: 'msg-1', content: { text: 'first' } }, { type: 'system_response_message', status: 'in_progress', conversation_id: 'test-conv', id: 'msg-2', content: { text: 'second' } } ]; const processedMessages: any[] = []; // Process in arrival order (which is out of sequence) outOfOrderMessages.forEach(msg => { processedMessages.push({ role: 'assistant', content: msg.content.text, id: msg.id, timestamp: Date.now() }); }); // Should preserve arrival order rather than trying to reorder expect(processedMessages[0].content).toBe('third'); expect(processedMessages[1].content).toBe('first'); expect(processedMessages[2].content).toBe('second'); }); /** * Description: Verifies that different WebSocket message types can be processed concurrently without conflicts * Success: Multiple message types (response, intermediate, system) are handled simultaneously without interference */ test('handles concurrent message types correctly', () => { const conversation = { id: 'test-conv', messages: [] }; // Mix of message types arriving concurrently const mixedMessages = [ { type: 'system_response_message', status: 'in_progress', conversation_id: 'test-conv', content: { text: 'Response text' } }, { type: 'system_intermediate_message', conversation_id: 'test-conv', content: { name: 'Step 1', payload: 'Processing...' } }, { type: 'system_response_message', status: 'in_progress', conversation_id: 'test-conv', content: { text: ' continued' } }, { type: 'error', conversation_id: 'test-conv', content: { text: 'Warning message' } } ]; let currentMessages = conversation.messages; mixedMessages.forEach(msg => { if (msg.type === 'system_response_message') { // Append to or create assistant message const lastMessage = currentMessages[currentMessages.length - 1]; if (lastMessage && lastMessage.role === 'assistant') { currentMessages = currentMessages.map((m, idx) => idx === currentMessages.length - 1 ? { ...m, content: (m.content || '') + msg.content.text } : m ); } else { currentMessages = [...currentMessages, { role: 'assistant', content: msg.content.text, intermediateSteps: [], errorMessages: [] }]; } } else if (msg.type === 'system_intermediate_message') { // Add intermediate step const lastMessage = currentMessages[currentMessages.length - 1]; if (lastMessage && lastMessage.role === 'assistant') { currentMessages = currentMessages.map((m, idx) => idx === currentMessages.length - 1 ? { ...m, intermediateSteps: [...(m.intermediateSteps || []), msg] } : m ); } } else if (msg.type === 'error') { // Add error message const lastMessage = currentMessages[currentMessages.length - 1]; if (lastMessage && lastMessage.role === 'assistant') { currentMessages = currentMessages.map((m, idx) => idx === currentMessages.length - 1 ? { ...m, errorMessages: [...(m.errorMessages || []), msg] } : m ); } } }); expect(currentMessages).toHaveLength(1); const assistantMessage = currentMessages[0]; expect(assistantMessage.content).toBe('Response text continued'); expect(assistantMessage.intermediateSteps).toHaveLength(1); expect(assistantMessage.errorMessages).toHaveLength(1); }); }); describe('Connection State Management', () => { /** * Description: Verifies that WebSocket connection state changes are tracked and reported accurately * Success: Connection state (connecting, connected, disconnected, error) is accurately maintained and updated */ test('tracks connection state changes accurately', () => { const ws = new MockWebSocket('ws://test-server.com/websocket'); let connectionState = 'connecting'; let retryCount = 0; ws.onopen = () => { connectionState = 'connected'; retryCount = 0; }; ws.onclose = () => { connectionState = 'disconnected'; retryCount++; }; ws.onerror = () => { connectionState = 'error'; }; // Simulate connection lifecycle expect(connectionState).toBe('connecting'); ws.readyState = MockWebSocket.OPEN; if (ws.onopen) ws.onopen(new Event('open')); expect(connectionState).toBe('connected'); expect(retryCount).toBe(0); ws.readyState = MockWebSocket.CLOSED; if (ws.onclose) ws.onclose(new CloseEvent('close')); expect(connectionState).toBe('disconnected'); expect(retryCount).toBe(1); if (ws.onerror) ws.onerror(new Event('error')); expect(connectionState).toBe('error'); }); /** * Description: Verifies that multiple simultaneous WebSocket connection attempts are prevented * Success: Only one connection attempt is active at a time, subsequent attempts are queued or ignored */ test('prevents multiple simultaneous connection attempts', () => { let connectionAttempts = 0; let isConnecting = false; const attemptConnection = async () => { if (isConnecting) { return false; // Prevent concurrent attempts } isConnecting = true; connectionAttempts++; try { const ws = new MockWebSocket('ws://test-server.com/websocket'); return new Promise(resolve => { setTimeout(() => { ws.readyState = MockWebSocket.OPEN; if (ws.onopen) ws.onopen(new Event('open')); isConnecting = false; resolve(true); }, 100); }); } catch { isConnecting = false; return false; } }; // Try to start multiple connections simultaneously const promises = [ attemptConnection(), attemptConnection(), attemptConnection() ]; jest.runAllTimers(); // Only first attempt should proceed expect(connectionAttempts).toBe(1); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/components/Chat.websocket.test.tsx ================================================ /** * WebSocket tests including session cookie handling and stop generating functionality */ import MockWebSocket from '@/__mocks__/websocket'; import { SESSION_COOKIE_NAME } from '@/constants/constants'; // Import type definitions for testing interaction message handling import { isSystemInteractionMessage, isOAuthConsentMessage, extractOAuthUrl, } from '@/types/websocket'; import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { InteractionModal } from '@/components/Chat/ChatInteractionMessage'; // Mock react-hot-toast for notification tests jest.mock('react-hot-toast', () => ({ __esModule: true, default: { custom: jest.fn(), dismiss: jest.fn(), }, toast: { custom: jest.fn(), dismiss: jest.fn(), }, })); describe('WebSocket Functionality', () => { beforeEach(() => { MockWebSocket.lastInstance = null; }); describe('Session Cookie Handling', () => { it('should always send session cookies with WebSocket connections using the correct constant', () => { // Test that session cookie is properly extracted and appended to WebSocket URL const mockSessionId = 'test_session_12345'; const baseUrl = 'ws://test-server.com/websocket'; // Simulate the cookie extraction logic from the actual implementation const mockDocumentCookie = `other=value; ${SESSION_COOKIE_NAME}=${mockSessionId}; another=test`; // Extract cookie using the same logic as the real implementation const getCookie = (name: string, documentCookie: string) => { const value = `; ${documentCookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift(); return null; }; const sessionCookie = getCookie(SESSION_COOKIE_NAME, mockDocumentCookie); // Build WebSocket URL with session cookie (same logic as real implementation) let wsUrl = baseUrl; if (sessionCookie) { const separator = wsUrl.includes('?') ? '&' : '?'; wsUrl += `${separator}session=${encodeURIComponent(sessionCookie)}`; } // Verify the session cookie was found and URL was built correctly expect(sessionCookie).toBe(mockSessionId); expect(wsUrl).toBe(`${baseUrl}?session=${encodeURIComponent(mockSessionId)}`); // Verify WebSocket is created with the session cookie const ws = new MockWebSocket(wsUrl); expect(ws.url).toContain(`session=${encodeURIComponent(mockSessionId)}`); expect(ws.url).toContain(SESSION_COOKIE_NAME.replace('nemo-agent-toolkit-session', 'session')); // URL param vs cookie name }); it('should use the correct session cookie constant name', () => { // Verify we're using the constant and not a hardcoded value expect(SESSION_COOKIE_NAME).toBe('nemo-agent-toolkit-session'); // Test with the actual constant const mockCookie = `test=value; ${SESSION_COOKIE_NAME}=session123; other=value`; const getCookie = (name: string, documentCookie: string) => { const value = `; ${documentCookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift(); return null; }; const result = getCookie(SESSION_COOKIE_NAME, mockCookie); expect(result).toBe('session123'); }); it('should handle missing session cookies gracefully', () => { const baseUrl = 'ws://test-server.com/websocket'; const mockDocumentCookie = 'other=value; different=cookie'; const getCookie = (name: string, documentCookie: string) => { const value = `; ${documentCookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift(); return null; }; const sessionCookie = getCookie(SESSION_COOKIE_NAME, mockDocumentCookie); // Should be null when cookie not found expect(sessionCookie).toBeNull(); // URL should remain unchanged let wsUrl = baseUrl; if (sessionCookie) { const separator = wsUrl.includes('?') ? '&' : '?'; wsUrl += `${separator}session=${encodeURIComponent(sessionCookie)}`; } expect(wsUrl).toBe(baseUrl); // No session parameter added }); }); describe('Stop Generating Functionality', () => { it('should track active user message ID for stop generating', () => { const activeUserMessageId = { current: null as string | null }; // Simulate sending a message const messageId = 'user-msg-123'; activeUserMessageId.current = messageId; expect(activeUserMessageId.current).toBe(messageId); // Simulate stop generating activeUserMessageId.current = null; expect(activeUserMessageId.current).toBeNull(); }); it('should ignore WebSocket messages when activeUserMessageId is null', () => { const activeUserMessageId = { current: null as string | null }; const shouldIgnoreMessage = (message: any) => { const messageParentId = message.parent_id; if (messageParentId) { if (activeUserMessageId.current === null || messageParentId !== activeUserMessageId.current) { return true; } } return false; }; // Test with null activeUserMessageId (stop was clicked) const message = { parent_id: 'some-message-id', type: 'system_response_message' }; expect(shouldIgnoreMessage(message)).toBe(true); }); it('should process WebSocket messages when activeUserMessageId matches parent_id', () => { const activeUserMessageId = { current: 'active-msg-123' }; const shouldIgnoreMessage = (message: any) => { const messageParentId = message.parent_id; if (messageParentId) { if (activeUserMessageId.current === null || messageParentId !== activeUserMessageId.current) { return true; } } return false; }; // Test with matching parent_id const message = { parent_id: 'active-msg-123', type: 'system_response_message' }; expect(shouldIgnoreMessage(message)).toBe(false); }); }); describe('WebSocket Mock Integration', () => { it('should properly track WebSocket instances', () => { const ws1 = new MockWebSocket('ws://test1.com'); expect(MockWebSocket.lastInstance).toBe(ws1); const ws2 = new MockWebSocket('ws://test2.com'); expect(MockWebSocket.lastInstance).toBe(ws2); }); it('should create WebSocket with session cookie in URL', () => { const sessionId = 'integration_test_session'; const wsUrl = `ws://test.com/websocket?session=${encodeURIComponent(sessionId)}`; const ws = new MockWebSocket(wsUrl); expect(ws.url).toBe(wsUrl); expect(ws.url).toContain('session='); expect(ws.url).toContain(encodeURIComponent(sessionId)); }); }); describe('Message Processing Logic', () => { describe('Message Validation', () => { it('should validate message with required conversation_id', () => { const validMessage = { type: 'system_response_message', conversation_id: 'conv-123', content: { text: 'Hello' }, status: 'in_progress' }; // Mock the validation function behavior const validateWebSocketMessageWithConversationId = (message: any) => { if (!message.conversation_id) { throw new Error('conversation_id is required'); } if (!message.type) { throw new Error('type is required'); } }; expect(() => validateWebSocketMessageWithConversationId(validMessage)).not.toThrow(); }); it('should reject message without conversation_id', () => { const invalidMessage = { type: 'system_response_message', content: { text: 'Hello' }, status: 'in_progress' }; const validateWebSocketMessageWithConversationId = (message: any) => { if (!message.conversation_id) { throw new Error('conversation_id is required'); } if (!message.type) { throw new Error('type is required'); } }; expect(() => validateWebSocketMessageWithConversationId(invalidMessage)) .toThrow('conversation_id is required'); }); it('should reject message without type', () => { const invalidMessage = { conversation_id: 'conv-123', content: { text: 'Hello' }, status: 'in_progress' }; const validateWebSocketMessageWithConversationId = (message: any) => { if (!message.conversation_id) { throw new Error('conversation_id is required'); } if (!message.type) { throw new Error('type is required'); } }; expect(() => validateWebSocketMessageWithConversationId(invalidMessage)) .toThrow('type is required'); }); }); describe('Message Type Processing', () => { it('should identify system response messages', () => { const isSystemResponseMessage = (message: any) => { return message.type === 'system_response_message'; }; const systemMessage = { type: 'system_response_message', conversation_id: 'conv-123', content: { text: 'AI response' } }; const userMessage = { type: 'user_message', conversation_id: 'conv-123', content: { text: 'User input' } }; expect(isSystemResponseMessage(systemMessage)).toBe(true); expect(isSystemResponseMessage(userMessage)).toBe(false); }); it('should identify intermediate step messages', () => { const isSystemIntermediateMessage = (message: any) => { return message.type === 'system_intermediate_step'; }; const intermediateMessage = { type: 'system_intermediate_step', conversation_id: 'conv-123', content: { text: 'Processing step 1...' } }; const regularMessage = { type: 'system_response_message', conversation_id: 'conv-123', content: { text: 'Final response' } }; expect(isSystemIntermediateMessage(intermediateMessage)).toBe(true); expect(isSystemIntermediateMessage(regularMessage)).toBe(false); }); it('should identify error messages', () => { const isErrorMessage = (message: any) => { return message.type === 'error' || message.status === 'error'; }; const errorMessage = { type: 'error', conversation_id: 'conv-123', content: { text: 'Something went wrong' } }; const statusErrorMessage = { type: 'system_response_message', status: 'error', conversation_id: 'conv-123', content: { text: 'Processing failed' } }; const normalMessage = { type: 'system_response_message', status: 'in_progress', conversation_id: 'conv-123', content: { text: 'Working...' } }; expect(isErrorMessage(errorMessage)).toBe(true); expect(isErrorMessage(statusErrorMessage)).toBe(true); expect(isErrorMessage(normalMessage)).toBe(false); }); it('should identify system response complete messages', () => { const isSystemResponseComplete = (message: any) => { return message.type === 'system_response:complete' || message.status === 'complete'; }; const completeMessage = { type: 'system_response:complete', conversation_id: 'conv-123' }; const statusCompleteMessage = { type: 'system_response_message', status: 'complete', conversation_id: 'conv-123' }; const inProgressMessage = { type: 'system_response_message', status: 'in_progress', conversation_id: 'conv-123' }; expect(isSystemResponseComplete(completeMessage)).toBe(true); expect(isSystemResponseComplete(statusCompleteMessage)).toBe(true); expect(isSystemResponseComplete(inProgressMessage)).toBe(false); }); }); describe('Conversation Updates and State Synchronization', () => { it('should update conversation with new assistant message', () => { const conversation = { id: 'conv-123', name: 'Test Chat', messages: [ { id: 'msg-1', role: 'user', content: 'Hello' } ] }; const wsMessage = { type: 'system_response_message', conversation_id: 'conv-123', content: { text: 'Hi there!' }, status: 'in_progress' }; // Simulate message processing const processSystemResponseMessage = (message: any, messages: any[]) => { const lastMessage = messages[messages.length - 1]; if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === '') { // Update existing assistant message return messages.map((msg, index) => index === messages.length - 1 ? { ...msg, content: message.content.text } : msg ); } else { // Add new assistant message return [...messages, { id: `assistant-${Date.now()}`, role: 'assistant', content: message.content.text }]; } }; const updatedMessages = processSystemResponseMessage(wsMessage, conversation.messages); expect(updatedMessages).toHaveLength(2); expect(updatedMessages[1].role).toBe('assistant'); expect(updatedMessages[1].content).toBe('Hi there!'); }); it('should append to existing assistant message when streaming', () => { const conversation = { id: 'conv-123', name: 'Test Chat', messages: [ { id: 'msg-1', role: 'user', content: 'Hello' }, { id: 'msg-2', role: 'assistant', content: 'Hi ' } ] }; const wsMessage = { type: 'system_response_message', conversation_id: 'conv-123', content: { text: 'there!' }, status: 'in_progress' }; const appendAssistantText = (messages: any[], newText: string) => { const lastMessage = messages[messages.length - 1]; if (lastMessage && lastMessage.role === 'assistant') { return messages.map((msg, index) => index === messages.length - 1 ? { ...msg, content: msg.content + newText } : msg ); } return messages; }; const updatedMessages = appendAssistantText(conversation.messages, wsMessage.content.text); expect(updatedMessages[1].content).toBe('Hi there!'); }); it('should maintain conversation reference integrity', () => { const conversationsRef = { current: [ { id: 'conv-1', name: 'Chat 1', messages: [] }, { id: 'conv-2', name: 'Chat 2', messages: [] } ]}; const selectedConversationRef = { current: conversationsRef.current[0] }; // Simulate updating a conversation const updateRefsAndDispatch = (updatedConversations: any[], updatedConversation: any, currentSelected: any) => { conversationsRef.current = updatedConversations; if (currentSelected?.id === updatedConversation.id) { selectedConversationRef.current = updatedConversation; } }; const updatedConv = { ...conversationsRef.current[0], name: 'Updated Chat 1' }; const updatedConversations = conversationsRef.current.map(c => c.id === updatedConv.id ? updatedConv : c ); updateRefsAndDispatch(updatedConversations, updatedConv, selectedConversationRef.current); expect(conversationsRef.current[0].name).toBe('Updated Chat 1'); expect(selectedConversationRef.current.name).toBe('Updated Chat 1'); }); }); describe('OAuth Consent Handling', () => { it('should identify OAuth consent messages', () => { const isSystemInteractionMessage = (message: any) => { return message.type === 'system_interaction_message'; }; const oauthMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'oauth_consent', oauth_url: 'https://auth.example.com/oauth/authorize?client_id=123' } }; const regularMessage = { type: 'system_response_message', conversation_id: 'conv-123', content: { text: 'Regular response' } }; expect(isSystemInteractionMessage(oauthMessage)).toBe(true); expect(isSystemInteractionMessage(regularMessage)).toBe(false); }); it('should extract OAuth URL from consent message', () => { const extractOAuthUrl = (message: any) => { return message?.content?.oauth_url || message?.content?.redirect_url || message?.content?.text; }; const oauthMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://auth.example.com/oauth/authorize' } }; const redirectMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', redirect_url: 'https://auth.example.com/redirect' } }; const textMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', text: 'https://auth.example.com/text' } }; expect(extractOAuthUrl(oauthMessage)).toBe('https://auth.example.com/oauth/authorize'); expect(extractOAuthUrl(redirectMessage)).toBe('https://auth.example.com/redirect'); expect(extractOAuthUrl(textMessage)).toBe('https://auth.example.com/text'); }); it('should handle OAuth consent message processing', () => { const handleOAuthConsent = (message: any) => { if (message.type !== 'system_interaction_message') return false; if (message.content?.input_type === 'oauth_consent') { const oauthUrl = message?.content?.oauth_url || message?.content?.redirect_url || message?.content?.text; if (oauthUrl) { // In real implementation, this would open a popup // For testing, we'll just return the URL return { opened: true, url: oauthUrl }; } return { opened: false, error: 'No URL found' }; } return false; }; const oauthMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://auth.example.com/oauth' } }; const nonOAuthMessage = { type: 'system_interaction_message', content: { input_type: 'user_input', text: 'Please enter your name' } }; const result1 = handleOAuthConsent(oauthMessage); const result2 = handleOAuthConsent(nonOAuthMessage); expect(result1).toEqual({ opened: true, url: 'https://auth.example.com/oauth' }); expect(result2).toBe(false); }); }); describe('Intermediate Steps Filtering', () => { it('should respect enableIntermediateSteps session storage setting', () => { const mockSessionStorage = { 'enableIntermediateSteps': 'false' }; const shouldProcessIntermediateStep = (message: any) => { if (mockSessionStorage['enableIntermediateSteps'] === 'false' && message.type === 'system_intermediate_step') { return false; } return true; }; const intermediateMessage = { type: 'system_intermediate_step', conversation_id: 'conv-123', content: { text: 'Processing...' } }; const regularMessage = { type: 'system_response_message', conversation_id: 'conv-123', content: { text: 'Final result' } }; expect(shouldProcessIntermediateStep(intermediateMessage)).toBe(false); expect(shouldProcessIntermediateStep(regularMessage)).toBe(true); }); it('should process intermediate steps when enabled', () => { const mockSessionStorage = { 'enableIntermediateSteps': 'true' }; const shouldProcessIntermediateStep = (message: any) => { if (mockSessionStorage['enableIntermediateSteps'] === 'false' && message.type === 'system_intermediate_step') { return false; } return true; }; const intermediateMessage = { type: 'system_intermediate_step', conversation_id: 'conv-123', content: { text: 'Processing step 1...' } }; expect(shouldProcessIntermediateStep(intermediateMessage)).toBe(true); }); it('should handle missing enableIntermediateSteps setting', () => { const mockSessionStorage = {}; const shouldProcessIntermediateStep = (message: any) => { const setting = (mockSessionStorage as any)['enableIntermediateSteps']; if (setting === 'false' && message.type === 'system_intermediate_step') { return false; } return true; }; const intermediateMessage = { type: 'system_intermediate_step', conversation_id: 'conv-123', content: { text: 'Processing...' } }; // Should default to processing when setting is undefined expect(shouldProcessIntermediateStep(intermediateMessage)).toBe(true); }); }); describe('Message Persistence and Ref Updates', () => { it('should update conversations ref before React dispatch', () => { const conversationsRef = { current: [ { id: 'conv-1', messages: [] } ]}; const selectedConversationRef = { current: conversationsRef.current[0] }; let dispatchCalls: any[] = []; const mockDispatch = (action: any) => { dispatchCalls.push(action); }; const updateRefsAndDispatch = (updatedConversations: any[], updatedConversation: any, currentSelected: any) => { // Update refs BEFORE dispatch to prevent stale reads conversationsRef.current = updatedConversations; if (currentSelected?.id === updatedConversation.id) { selectedConversationRef.current = updatedConversation; } // Then dispatch to trigger React re-renders mockDispatch({ field: 'conversations', value: updatedConversations }); if (currentSelected?.id === updatedConversation.id) { mockDispatch({ field: 'selectedConversation', value: updatedConversation }); } }; const updatedConv = { id: 'conv-1', messages: [{ id: 'msg-1', content: 'test' }] }; const updatedConversations = [updatedConv]; updateRefsAndDispatch(updatedConversations, updatedConv, selectedConversationRef.current); // Refs should be updated immediately expect(conversationsRef.current).toEqual(updatedConversations); expect(selectedConversationRef.current).toEqual(updatedConv); // Dispatch should be called expect(dispatchCalls).toHaveLength(2); expect(dispatchCalls[0]).toEqual({ field: 'conversations', value: updatedConversations }); expect(dispatchCalls[1]).toEqual({ field: 'selectedConversation', value: updatedConv }); }); it('should handle conversation not found scenario', () => { const conversationsRef = { current: [ { id: 'conv-1', messages: [] } ]}; const findTargetConversation = (conversationId: string) => { return conversationsRef.current.find(c => c.id === conversationId); }; const handleConversationNotFound = (conversationId: string) => { const errorMsg = `WebSocket message received for unknown conversation ID: ${conversationId}`; return { error: errorMsg, shouldReturn: true }; }; // Test with existing conversation expect(findTargetConversation('conv-1')).toBeDefined(); // Test with non-existing conversation expect(findTargetConversation('conv-999')).toBeUndefined(); const error = handleConversationNotFound('conv-999'); expect(error.error).toContain('unknown conversation ID: conv-999'); expect(error.shouldReturn).toBe(true); }); it('should properly chain message processing functions', () => { const initialMessages = [ { id: 'msg-1', role: 'user', content: 'Hello' } ]; const processSystemResponseMessage = (message: any, messages: any[]) => { if (message.type === 'system_response_message') { return [...messages, { id: 'assistant-1', role: 'assistant', content: message.content.text }]; } return messages; }; const processIntermediateStepMessage = (message: any, messages: any[]) => { if (message.type === 'system_intermediate_step') { return [...messages, { id: 'step-1', role: 'system', content: message.content.text }]; } return messages; }; const processErrorMessage = (message: any, messages: any[]) => { if (message.type === 'error') { return [...messages, { id: 'error-1', role: 'system', content: `Error: ${message.content.text}` }]; } return messages; }; // Test system response processing const systemMessage = { type: 'system_response_message', content: { text: 'AI response' } }; let updatedMessages = initialMessages; updatedMessages = processSystemResponseMessage(systemMessage, updatedMessages); updatedMessages = processIntermediateStepMessage(systemMessage, updatedMessages); updatedMessages = processErrorMessage(systemMessage, updatedMessages); expect(updatedMessages).toHaveLength(2); expect(updatedMessages[1].role).toBe('assistant'); expect(updatedMessages[1].content).toBe('AI response'); // Test intermediate step processing const intermediateMessage = { type: 'system_intermediate_step', content: { text: 'Processing...' } }; updatedMessages = processIntermediateStepMessage(intermediateMessage, updatedMessages); expect(updatedMessages).toHaveLength(3); expect(updatedMessages[2].role).toBe('system'); expect(updatedMessages[2].content).toBe('Processing...'); }); }); }); describe('System Interaction Message Handling', () => { // Mock modal state for testing let modalOpen = false; let currentInteractionMessage: any = null; // Helper functions to simulate Chat component behavior const openModal = (message: any) => { modalOpen = true; currentInteractionMessage = message; }; const closeModal = () => { modalOpen = false; currentInteractionMessage = null; }; // Helper function to simulate OAuth consent handling const handleOAuthConsent = (message: any) => { if (!isSystemInteractionMessage(message)) return false; if (message.content?.input_type === 'oauth_consent') { const oauthUrl = extractOAuthUrl(message); if (oauthUrl) { // In real implementation, this would open a popup window.open(oauthUrl, '_blank'); return true; } else { console.error('OAuth consent message received but no URL found in content:', message?.content); return false; } } return false; }; // Helper function to simulate WebSocket message processing const processWebSocketMessage = (message: any) => { // Reset state modalOpen = false; currentInteractionMessage = null; // Simulate the actual Chat component logic if (isSystemInteractionMessage(message)) { // Check for OAuth consent message and handle specially if (isOAuthConsentMessage(message)) { return handleOAuthConsent(message); } // For other interaction messages, open modal openModal(message); return true; } return false; }; beforeEach(() => { modalOpen = false; currentInteractionMessage = null; jest.clearAllMocks(); }); describe('Interaction Message Detection and Processing', () => { it('should detect and process OAuth consent interaction message', () => { const oauthInteractionMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'oauth_consent', oauth_url: 'https://auth.example.com/oauth', text: 'Please authorize the application to access your data.' } }; // Mock window.open const mockWindowOpen = jest.spyOn(window, 'open').mockImplementation(); const result = processWebSocketMessage(oauthInteractionMessage); // Should be processed as OAuth consent (not regular modal) expect(result).toBe(true); expect(mockWindowOpen).toHaveBeenCalledWith('https://auth.example.com/oauth', '_blank'); expect(modalOpen).toBe(false); // OAuth should not open modal mockWindowOpen.mockRestore(); }); it('should open modal for user input interaction message', () => { const userInputMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'user_input', text: 'Please enter your name:', placeholder: 'Your full name' } }; const result = processWebSocketMessage(userInputMessage); // Should open modal for user input expect(result).toBe(true); expect(modalOpen).toBe(true); expect(currentInteractionMessage).toEqual(userInputMessage); }); it('should open modal for file upload interaction message', () => { const fileUploadMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'file_upload', text: 'Please upload a document for analysis:', accepted_file_types: ['.pdf', '.docx', '.txt'], max_file_size: '10MB' } }; const result = processWebSocketMessage(fileUploadMessage); // Should open modal for file upload expect(result).toBe(true); expect(modalOpen).toBe(true); expect(currentInteractionMessage).toEqual(fileUploadMessage); }); it('should open modal for confirmation interaction message', () => { const confirmationMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'confirmation', text: 'Are you sure you want to proceed with this action?', confirm_text: 'Yes, proceed', cancel_text: 'Cancel' } }; const result = processWebSocketMessage(confirmationMessage); // Should open modal for confirmation expect(result).toBe(true); expect(modalOpen).toBe(true); expect(currentInteractionMessage).toEqual(confirmationMessage); }); it('should not process non-interaction messages', () => { const regularMessage = { type: 'system_response_message', conversation_id: 'conv-123', status: 'in_progress', content: { text: 'This is a regular response message' } }; const result = processWebSocketMessage(regularMessage); // Should not process regular messages expect(result).toBe(false); expect(modalOpen).toBe(false); expect(currentInteractionMessage).toBeNull(); }); }); describe('Modal State Management', () => { it('should manage modal state correctly', () => { // Initially closed expect(modalOpen).toBe(false); expect(currentInteractionMessage).toBeNull(); // Open modal const testMessage = { type: 'system_interaction_message', content: { input_type: 'user_input', text: 'Test' } }; openModal(testMessage); expect(modalOpen).toBe(true); expect(currentInteractionMessage).toEqual(testMessage); // Close modal closeModal(); expect(modalOpen).toBe(false); expect(currentInteractionMessage).toBeNull(); }); }); describe('OAuth Consent Special Handling', () => { beforeEach(() => { // Mock window.open jest.spyOn(window, 'open').mockImplementation(); }); afterEach(() => { jest.restoreAllMocks(); }); it('should open OAuth URL directly without modal for oauth_consent messages', () => { const oauthMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'oauth_consent', oauth_url: 'https://auth.example.com/oauth/authorize' } }; const result = processWebSocketMessage(oauthMessage); // OAuth URL should be opened in new tab expect(window.open).toHaveBeenCalledWith('https://auth.example.com/oauth/authorize', '_blank'); // Should return true (processed) but modal should NOT be opened expect(result).toBe(true); expect(modalOpen).toBe(false); }); it('should handle OAuth message with redirect_url fallback', () => { const oauthMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'oauth_consent', redirect_url: 'https://auth.example.com/redirect' } }; const result = processWebSocketMessage(oauthMessage); expect(window.open).toHaveBeenCalledWith('https://auth.example.com/redirect', '_blank'); expect(result).toBe(true); expect(modalOpen).toBe(false); }); it('should handle OAuth message with text fallback', () => { const oauthMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'oauth_consent', text: 'https://auth.example.com/fallback' } }; const result = processWebSocketMessage(oauthMessage); expect(window.open).toHaveBeenCalledWith('https://auth.example.com/fallback', '_blank'); expect(result).toBe(true); expect(modalOpen).toBe(false); }); it('should handle OAuth message without valid URL gracefully', () => { // Mock console.error to verify error logging const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const oauthMessage = { type: 'system_interaction_message', conversation_id: 'conv-123', content: { input_type: 'oauth_consent' // No oauth_url, redirect_url, or text with URL } }; const result = processWebSocketMessage(oauthMessage); // Should not try to open any URL expect(window.open).not.toHaveBeenCalled(); // Should log error about missing URL expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('OAuth consent message received but no URL found'), expect.any(Object) ); // Should return false (not processed successfully) expect(result).toBe(false); expect(modalOpen).toBe(false); consoleSpy.mockRestore(); }); }); describe('Interaction Message Type Coverage', () => { it('should handle various interaction message types', () => { const testCases = [ { name: 'user_input', message: { type: 'system_interaction_message', content: { input_type: 'user_input', text: 'Enter name:' } } }, { name: 'file_upload', message: { type: 'system_interaction_message', content: { input_type: 'file_upload', text: 'Upload file:' } } }, { name: 'confirmation', message: { type: 'system_interaction_message', content: { input_type: 'confirmation', text: 'Confirm action?' } } }, { name: 'selection', message: { type: 'system_interaction_message', content: { input_type: 'selection', text: 'Choose option:', options: ['A', 'B'] } } } ]; testCases.forEach(({ name, message }) => { // Reset state for each test modalOpen = false; currentInteractionMessage = null; const result = processWebSocketMessage(message); expect(result).toBe(true); expect(modalOpen).toBe(true); expect(currentInteractionMessage).toEqual(message); }); }); it('should handle interaction messages without input_type', () => { const messageWithoutInputType = { type: 'system_interaction_message', content: { text: 'General interaction message' } }; const result = processWebSocketMessage(messageWithoutInputType); // Should still open modal for any interaction message expect(result).toBe(true); expect(modalOpen).toBe(true); expect(currentInteractionMessage).toEqual(messageWithoutInputType); }); }); describe('Error Handling and Edge Cases', () => { it('should handle interaction message with empty content', () => { const minimalMessage = { type: 'system_interaction_message', content: {} }; const result = processWebSocketMessage(minimalMessage); // Should still process message with empty content expect(result).toBe(true); expect(modalOpen).toBe(true); expect(currentInteractionMessage).toEqual(minimalMessage); }); it('should handle interaction message without content property', () => { const messageWithoutContent = { type: 'system_interaction_message' // No content property }; const result = processWebSocketMessage(messageWithoutContent); // Should still be identified as interaction message expect(isSystemInteractionMessage(messageWithoutContent)).toBe(true); expect(result).toBe(true); expect(modalOpen).toBe(true); }); it('should not confuse interaction messages with other message types', () => { const nonInteractionMessages = [ { type: 'system_response_message', content: { text: 'Response' } }, { type: 'system_intermediate_message', content: { text: 'Step' } }, { type: 'error', content: { text: 'Error' } }, { type: 'user_message', content: { text: 'User input' } } ]; nonInteractionMessages.forEach(message => { modalOpen = false; currentInteractionMessage = null; const result = processWebSocketMessage(message); expect(result).toBe(false); expect(modalOpen).toBe(false); expect(currentInteractionMessage).toBeNull(); }); }); }); }); describe('InteractionModal Component Tests', () => { const mockOnClose = jest.fn(); const mockOnSubmit = jest.fn(); beforeEach(() => { jest.clearAllMocks(); }); describe('Text Input Type', () => { it('should render text input with placeholder', () => { const message = { type: 'system_interaction_message', content: { input_type: 'text', text: 'Please enter your name:', placeholder: 'Your full name here', required: true } }; render( ); // Verify modal content expect(screen.getByText('Please enter your name:')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Your full name here')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); it('should handle text input submission', async () => { const message = { type: 'system_interaction_message', content: { input_type: 'text', text: 'Enter feedback:', required: false } }; render( ); const textarea = screen.getByRole('textbox'); const submitButton = screen.getByRole('button', { name: 'Submit' }); // Enter text and submit fireEvent.change(textarea, { target: { value: 'Great app!' } }); fireEvent.click(submitButton); expect(mockOnSubmit).toHaveBeenCalledWith({ interactionMessage: message, userResponse: 'Great app!' }); expect(mockOnClose).toHaveBeenCalled(); }); it('should validate required text input', async () => { const message = { type: 'system_interaction_message', content: { input_type: 'text', text: 'Required field:', required: true } }; render( ); const submitButton = screen.getByRole('button', { name: 'Submit' }); // Try to submit without entering text fireEvent.click(submitButton); // Should show error and not submit expect(screen.getByText('This field is required.')).toBeInTheDocument(); expect(mockOnSubmit).not.toHaveBeenCalled(); expect(mockOnClose).not.toHaveBeenCalled(); }); it('should handle cancel button', () => { const message = { type: 'system_interaction_message', content: { input_type: 'text', text: 'Enter something:' } }; render( ); const cancelButton = screen.getByRole('button', { name: 'Cancel' }); fireEvent.click(cancelButton); expect(mockOnClose).toHaveBeenCalled(); expect(mockOnSubmit).not.toHaveBeenCalled(); }); }); describe('Binary Choice Type', () => { it('should render binary choice options', () => { const message = { type: 'system_interaction_message', content: { input_type: 'binary_choice', text: 'Do you want to continue?', options: [ { id: 'continue', label: 'Continue', value: 'continue' }, { id: 'cancel', label: 'Cancel', value: 'cancel' } ] } }; render( ); expect(screen.getByText('Do you want to continue?')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); it('should handle binary choice selection', () => { const message = { type: 'system_interaction_message', content: { input_type: 'binary_choice', text: 'Proceed with action?', options: [ { id: 'yes', label: 'Yes, proceed', value: 'proceed' }, { id: 'no', label: 'No, cancel', value: 'cancel' } ] } }; render( ); const proceedButton = screen.getByRole('button', { name: 'Yes, proceed' }); fireEvent.click(proceedButton); expect(mockOnSubmit).toHaveBeenCalledWith({ interactionMessage: message, userResponse: 'proceed' }); expect(mockOnClose).toHaveBeenCalled(); }); it('should apply correct styling for continue vs cancel buttons', () => { const message = { type: 'system_interaction_message', content: { input_type: 'binary_choice', text: 'Choose action:', options: [ { id: 'cont', label: 'Continue', value: 'continue' }, { id: 'stop', label: 'Stop', value: 'stop' } ] } }; render( ); const continueButton = screen.getByRole('button', { name: 'Continue' }); const stopButton = screen.getByRole('button', { name: 'Stop' }); // Continue button should have green background expect(continueButton).toHaveClass('bg-[#76b900]'); // Stop button should have slate background expect(stopButton).toHaveClass('bg-slate-800'); }); }); describe('Radio Selection Type', () => { it('should render radio options', () => { const message = { type: 'system_interaction_message', content: { input_type: 'radio', text: 'Select notification method:', options: [ { id: 'email', label: 'Email', value: 'email' }, { id: 'sms', label: 'SMS', value: 'sms' }, { id: 'push', label: 'Push Notification', value: 'push' } ] } }; render( ); expect(screen.getByText('Select notification method:')).toBeInTheDocument(); expect(screen.getByLabelText('Email')).toBeInTheDocument(); expect(screen.getByLabelText('SMS')).toBeInTheDocument(); expect(screen.getByLabelText('Push Notification')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); it('should handle radio selection and submission', () => { const message = { type: 'system_interaction_message', content: { input_type: 'radio', text: 'Choose option:', options: [ { id: 'opt1', label: 'Option 1', value: 'option1' }, { id: 'opt2', label: 'Option 2', value: 'option2' } ] } }; render( ); const option1Radio = screen.getByLabelText('Option 1'); const submitButton = screen.getByRole('button', { name: 'Submit' }); // Select option and submit fireEvent.click(option1Radio); fireEvent.click(submitButton); expect(mockOnSubmit).toHaveBeenCalledWith({ interactionMessage: message, userResponse: 'option1' }); expect(mockOnClose).toHaveBeenCalled(); }); it('should validate required radio selection', () => { const message = { type: 'system_interaction_message', content: { input_type: 'radio', text: 'Required selection:', required: true, options: [ { id: 'opt1', label: 'Option 1', value: 'option1' }, { id: 'opt2', label: 'Option 2', value: 'option2' } ] } }; render( ); const submitButton = screen.getByRole('button', { name: 'Submit' }); // Try to submit without selecting fireEvent.click(submitButton); expect(screen.getByText('Please select an option.')).toBeInTheDocument(); expect(mockOnSubmit).not.toHaveBeenCalled(); expect(mockOnClose).not.toHaveBeenCalled(); }); }); describe('Notification Type', () => { it('should display toast notification instead of modal', () => { const { toast } = require('react-hot-toast'); const message = { type: 'system_interaction_message', content: { input_type: 'notification', text: 'Operation completed successfully!' } }; const result = render( ); // Should call toast.custom instead of rendering modal expect(toast.custom).toHaveBeenCalled(); // Should return null (no modal content) expect(result.container.firstChild).toBeNull(); }); it('should handle notification with custom content', () => { const { toast } = require('react-hot-toast'); const message = { type: 'system_interaction_message', content: { input_type: 'notification', text: 'Custom notification message' } }; render( ); expect(toast.custom).toHaveBeenCalledWith( expect.any(Function), { position: 'top-right', duration: Infinity, id: 'notification-toast' } ); }); it('should handle notification without content gracefully', () => { const { toast } = require('react-hot-toast'); const message = { type: 'system_interaction_message', content: { input_type: 'notification' // No text field } }; render( ); // Should still call toast.custom expect(toast.custom).toHaveBeenCalled(); }); }); describe('Modal State and Edge Cases', () => { it('should not render when isOpen is false', () => { const message = { type: 'system_interaction_message', content: { input_type: 'text', text: 'Test message' } }; const result = render( ); expect(result.container.firstChild).toBeNull(); }); it('should not render when interactionMessage is null', () => { const result = render( ); expect(result.container.firstChild).toBeNull(); }); it('should handle unknown input_type gracefully', () => { const message = { type: 'system_interaction_message', content: { input_type: 'unknown_type', text: 'Unknown interaction type' } }; render( ); // Should still show the text, even if no specific UI for the type expect(screen.getByText('Unknown interaction type')).toBeInTheDocument(); }); it('should handle message without input_type', () => { const message = { type: 'system_interaction_message', content: { text: 'General interaction message' // No input_type specified } }; render( ); // Should still display the text expect(screen.getByText('General interaction message')).toBeInTheDocument(); }); it('should handle empty content gracefully', () => { const message = { type: 'system_interaction_message', content: {} }; render( ); // Modal should still render with basic structure expect(document.querySelector('.fixed')).toBeInTheDocument(); }); }); describe('Validation Error States', () => { it('should clear validation errors when user corrects input', async () => { const message = { type: 'system_interaction_message', content: { input_type: 'text', text: 'Required field:', required: true } }; render( ); const textarea = screen.getByRole('textbox'); const submitButton = screen.getByRole('button', { name: 'Submit' }); // First, trigger validation error fireEvent.click(submitButton); expect(screen.getByText('This field is required.')).toBeInTheDocument(); // Then enter text and submit again fireEvent.change(textarea, { target: { value: 'Valid input' } }); fireEvent.click(submitButton); // Error should be cleared and submission should work expect(screen.queryByText('This field is required.')).not.toBeInTheDocument(); expect(mockOnSubmit).toHaveBeenCalledWith({ interactionMessage: message, userResponse: 'Valid input' }); }); it('should handle binary choice validation for required fields', async () => { const message = { type: 'system_interaction_message', content: { input_type: 'binary_choice', text: 'Required choice:', required: true, options: [ { id: 'opt1', label: 'Option 1', value: '' }, // Empty value { id: 'opt2', label: 'Option 2', value: 'valid' } ] } }; render( ); const emptyOption = screen.getByRole('button', { name: 'Option 1' }); fireEvent.click(emptyOption); // Wait for potential state update and check if validation error appears await waitFor(() => { // If error appears in the document const errorElement = screen.queryByText('Please select an option.'); if (errorElement) { expect(errorElement).toBeInTheDocument(); } }); // Should not call onSubmit for empty value expect(mockOnSubmit).not.toHaveBeenCalled(); }); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/types/websocket.test.ts ================================================ /** * Unit tests for WebSocket type guards and utility functions */ import { isSystemResponseMessage, isSystemResponseInProgress, isSystemResponseComplete, isSystemIntermediateMessage, isSystemInteractionMessage, isErrorMessage, isOAuthConsentMessage, validateWebSocketMessage, validateConversationId, validateWebSocketMessageWithConversationId, extractOAuthUrl, shouldAppendResponseContent, SystemResponseMessage, SystemIntermediateMessage, SystemInteractionMessage, ErrorMessage, } from '@/types/websocket'; describe('WebSocket Type Guards', () => { describe('isSystemResponseMessage', () => { it('returns true for valid system response message', () => { const message = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(isSystemResponseMessage(message)).toBe(true); }); it('returns false for other message types', () => { const message = { type: 'system_intermediate_message', content: { payload: 'data' }, }; expect(isSystemResponseMessage(message)).toBe(false); }); it('returns false for null/undefined', () => { expect(isSystemResponseMessage(null)).toBe(false); expect(isSystemResponseMessage(undefined)).toBe(false); }); }); describe('isSystemResponseInProgress', () => { it('returns true for in_progress system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(isSystemResponseInProgress(message)).toBe(true); }); it('returns false for complete system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'complete', content: { text: 'Hello' }, }; expect(isSystemResponseInProgress(message)).toBe(false); }); it('returns false for non-system response messages', () => { const message = { type: 'error', content: { text: 'Error' }, }; expect(isSystemResponseInProgress(message)).toBe(false); }); }); describe('isSystemResponseComplete', () => { it('returns true for complete system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'complete', }; expect(isSystemResponseComplete(message)).toBe(true); }); it('returns false for in_progress system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(isSystemResponseComplete(message)).toBe(false); }); }); describe('isSystemIntermediateMessage', () => { it('returns true for intermediate messages', () => { const message: SystemIntermediateMessage = { type: 'system_intermediate_message', content: { name: 'step', payload: 'data' }, }; expect(isSystemIntermediateMessage(message)).toBe(true); }); it('returns false for other message types', () => { const message = { type: 'system_response_message', status: 'complete', }; expect(isSystemIntermediateMessage(message)).toBe(false); }); }); describe('isSystemInteractionMessage', () => { it('returns true for interaction messages', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent' }, }; expect(isSystemInteractionMessage(message)).toBe(true); }); it('returns false for other message types', () => { const message = { type: 'error', content: { text: 'Error' }, }; expect(isSystemInteractionMessage(message)).toBe(false); }); }); describe('isErrorMessage', () => { it('returns true for error messages', () => { const message: ErrorMessage = { type: 'error', content: { text: 'Something went wrong' }, }; expect(isErrorMessage(message)).toBe(true); }); it('returns false for other message types', () => { const message = { type: 'system_response_message', status: 'complete', }; expect(isErrorMessage(message)).toBe(false); }); }); describe('isOAuthConsentMessage', () => { it('returns true for OAuth consent interaction messages', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.example.com', }, }; expect(isOAuthConsentMessage(message)).toBe(true); }); it('returns false for non-OAuth interaction messages', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'user_input' }, }; expect(isOAuthConsentMessage(message)).toBe(false); }); it('returns false for non-interaction messages', () => { const message = { type: 'error', content: { text: 'Error' }, }; expect(isOAuthConsentMessage(message)).toBe(false); }); }); describe('validateWebSocketMessage', () => { it('validates system response messages', () => { const message = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(validateWebSocketMessage(message)).toBe(true); }); it('validates intermediate messages', () => { const message = { type: 'system_intermediate_message', content: { payload: 'data' }, }; expect(validateWebSocketMessage(message)).toBe(true); }); it('validates interaction messages', () => { const message = { type: 'system_interaction_message', content: { input_type: 'oauth_consent' }, }; expect(validateWebSocketMessage(message)).toBe(true); }); it('validates error messages', () => { const message = { type: 'error', content: { text: 'Error occurred' }, }; expect(validateWebSocketMessage(message)).toBe(true); }); it('rejects invalid message types', () => { const message = { type: 'invalid_type', content: { text: 'Hello' }, }; expect(validateWebSocketMessage(message)).toBe(false); }); it('rejects messages without type', () => { const message = { content: { text: 'Hello' }, }; expect(validateWebSocketMessage(message)).toBe(false); }); it('rejects null/undefined messages', () => { expect(validateWebSocketMessage(null)).toBe(false); expect(validateWebSocketMessage(undefined)).toBe(false); }); it('rejects non-object messages', () => { expect(validateWebSocketMessage('string')).toBe(false); expect(validateWebSocketMessage(123)).toBe(false); }); }); describe('extractOAuthUrl', () => { it('extracts oauth_url from OAuth consent message', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.example.com/auth', }, }; expect(extractOAuthUrl(message)).toBe('https://oauth.example.com/auth'); }); it('extracts redirect_url when oauth_url is not available', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', redirect_url: 'https://redirect.example.com', }, }; expect(extractOAuthUrl(message)).toBe('https://redirect.example.com'); }); it('extracts text when neither oauth_url nor redirect_url is available', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', text: 'https://fallback.example.com', }, }; expect(extractOAuthUrl(message)).toBe('https://fallback.example.com'); }); it('returns null for non-OAuth consent messages', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'user_input' }, }; expect(extractOAuthUrl(message)).toBe(null); }); it('returns null when no URLs are available', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent' }, }; expect(extractOAuthUrl(message)).toBe(null); }); }); describe('shouldAppendResponseContent', () => { it('returns true for in_progress system response with text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello world' }, }; expect(shouldAppendResponseContent(message)).toBe(true); }); it('returns false for complete system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'complete', content: { text: 'Hello world' }, }; expect(shouldAppendResponseContent(message)).toBe(false); }); it('returns false for system response without text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: {}, }; expect(shouldAppendResponseContent(message)).toBe(false); }); it('returns false for system response with empty text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: '' }, }; expect(shouldAppendResponseContent(message)).toBe(false); }); it('returns false for non-system response messages', () => { const message: ErrorMessage = { type: 'error', content: { text: 'Error occurred' }, }; expect(shouldAppendResponseContent(message)).toBe(false); }); }); describe('validateConversationId', () => { it('returns true for valid conversation ID', () => { const message = { conversation_id: 'valid-conversation-123', type: 'system_response_message', }; expect(validateConversationId(message)).toBe(true); }); it('returns false for null message', () => { expect(validateConversationId(null)).toBe(false); }); it('returns false for undefined message', () => { expect(validateConversationId(undefined)).toBe(false); }); it('returns false for non-object message', () => { expect(validateConversationId('string')).toBe(false); expect(validateConversationId(123)).toBe(false); expect(validateConversationId(true)).toBe(false); }); it('returns false for missing conversation_id', () => { const message = { type: 'system_response_message', status: 'in_progress', }; expect(validateConversationId(message)).toBe(false); }); it('returns false for non-string conversation_id', () => { const message = { conversation_id: 123, type: 'system_response_message', }; expect(validateConversationId(message)).toBe(false); }); it('returns false for empty string conversation_id', () => { const message = { conversation_id: '', type: 'system_response_message', }; expect(validateConversationId(message)).toBe(false); }); it('returns false for whitespace-only conversation_id', () => { const message = { conversation_id: ' \n\t ', type: 'system_response_message', }; expect(validateConversationId(message)).toBe(false); }); it('returns true for conversation_id with whitespace that has content', () => { const message = { conversation_id: ' valid-id ', type: 'system_response_message', }; expect(validateConversationId(message)).toBe(true); }); }); describe('validateWebSocketMessageWithConversationId', () => { const validMessage = { type: 'system_response_message', conversation_id: 'valid-conversation-123', status: 'in_progress', content: { text: 'Hello' }, }; it('returns true for valid message with conversation ID', () => { expect(validateWebSocketMessageWithConversationId(validMessage)).toBe(true); }); it('throws error for invalid message structure', () => { const invalidMessage = { type: 'invalid_type', conversation_id: 'valid-conversation-123', }; expect(() => validateWebSocketMessageWithConversationId(invalidMessage)) .toThrow('Invalid WebSocket message structure'); }); it('throws error for null message', () => { expect(() => validateWebSocketMessageWithConversationId(null)) .toThrow('Invalid WebSocket message structure'); }); it('throws error for undefined message', () => { expect(() => validateWebSocketMessageWithConversationId(undefined)) .toThrow('Invalid WebSocket message structure'); }); it('throws error for missing conversation_id', () => { const messageWithoutConversationId = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(() => validateWebSocketMessageWithConversationId(messageWithoutConversationId)) .toThrow('WebSocket message missing required conversation_id'); }); it('throws error for empty conversation_id', () => { const messageWithEmptyConversationId = { type: 'system_response_message', conversation_id: '', status: 'in_progress', content: { text: 'Hello' }, }; expect(() => validateWebSocketMessageWithConversationId(messageWithEmptyConversationId)) .toThrow('WebSocket message missing required conversation_id'); }); it('throws error for whitespace-only conversation_id', () => { const messageWithWhitespaceConversationId = { type: 'system_response_message', conversation_id: ' \n\t ', status: 'in_progress', content: { text: 'Hello' }, }; expect(() => validateWebSocketMessageWithConversationId(messageWithWhitespaceConversationId)) .toThrow('WebSocket message missing required conversation_id'); }); it('error message includes message type and conversation_id for debugging', () => { const messageWithoutConversationId = { type: 'system_intermediate_message', status: 'in_progress', content: { name: 'Step 1' }, }; try { validateWebSocketMessageWithConversationId(messageWithoutConversationId); fail('Expected error to be thrown'); } catch (error: any) { expect(error.message).toContain('system_intermediate_message'); expect(error.message).toContain('conversation_id'); } }); it('error message includes full message JSON for debugging', () => { const invalidMessage = { type: 'invalid_type', some_field: 'some_value', }; try { validateWebSocketMessageWithConversationId(invalidMessage); fail('Expected error to be thrown'); } catch (error: any) { expect(error.message).toContain(JSON.stringify(invalidMessage)); } }); it('validates all supported message types with conversation_id', () => { const messageTypes = [ 'system_response_message', 'system_intermediate_message', 'system_interaction_message', 'error' ]; messageTypes.forEach(type => { const message = { type, conversation_id: 'valid-conversation-123', status: 'in_progress', content: { text: 'Test' }, }; expect(validateWebSocketMessageWithConversationId(message)).toBe(true); }); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/utils/app/importExports.test.ts ================================================ import { cleanData, isExportFormatV1, isExportFormatV2, isExportFormatV3, isExportFormatV4, isLatestExportFormat, } from '@/utils/app/importExport'; import { ExportFormatV1, ExportFormatV2, ExportFormatV4 } from '@/types/export'; // Jest syntax - no need to import describe, expect, it describe('Export Format Functions', () => { describe('isExportFormatV1', () => { it('should return true for v1 format', () => { const obj = [{ id: 1 }]; expect(isExportFormatV1(obj)).toBe(true); }); it('should return false for non-v1 formats', () => { const obj = { version: 3, history: [], folders: [] }; expect(isExportFormatV1(obj)).toBe(false); }); }); describe('isExportFormatV2', () => { it('should return true for v2 format', () => { const obj = { history: [], folders: [] }; expect(isExportFormatV2(obj)).toBe(true); }); it('should return false for non-v2 formats', () => { const obj = { version: 3, history: [], folders: [] }; expect(isExportFormatV2(obj)).toBe(false); }); }); describe('isExportFormatV3', () => { it('should return true for v3 format', () => { const obj = { version: 3, history: [], folders: [] }; expect(isExportFormatV3(obj)).toBe(true); }); it('should return false for non-v3 formats', () => { const obj = { version: 4, history: [], folders: [] }; expect(isExportFormatV3(obj)).toBe(false); }); }); describe('isExportFormatV4', () => { it('should return true for v4 format', () => { const obj = { version: 4, history: [], folders: [], prompts: [] }; expect(isExportFormatV4(obj)).toBe(true); }); it('should return false for non-v4 formats', () => { const obj = { version: 5, history: [], folders: [], prompts: [] }; expect(isExportFormatV4(obj)).toBe(false); }); }); }); describe('cleanData Functions', () => { describe('cleaning v1 data', () => { it('should return the latest format', () => { const data = [ { id: 1, name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], }, ] as ExportFormatV1; const obj = cleanData(data); expect(isLatestExportFormat(obj)).toBe(true); expect(obj).toEqual({ version: 4, history: [ { id: 1, name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], folderId: null, }, ], folders: [], prompts: [], }); }); }); describe('cleaning v2 data', () => { it('should return the latest format', () => { const data = { history: [ { id: '1', name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], }, ], folders: [ { id: 1, name: 'folder 1', }, ], } as ExportFormatV2; const obj = cleanData(data); expect(isLatestExportFormat(obj)).toBe(true); expect(obj).toEqual({ version: 4, history: [ { id: '1', name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], folderId: null, }, ], folders: [ { id: '1', name: 'folder 1', type: 'chat', }, ], prompts: [], }); }); }); describe('cleaning v4 data', () => { it('should return the latest format', () => { const data = { version: 4, history: [ { id: '1', name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], folderId: null, }, ], folders: [ { id: '1', name: 'folder 1', type: 'chat', }, ], } as ExportFormatV4; const obj = cleanData(data); expect(isLatestExportFormat(obj)).toBe(true); expect(obj).toEqual({ version: 4, history: [ { id: '1', name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], folderId: null, }, ], folders: [ { id: '1', name: 'folder 1', type: 'chat', }, ], }); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/__tests__/utils/chatTransform.test.ts ================================================ /** * Unit tests for pure chat transformation helpers * These tests verify the core business logic without side effects */ import { shouldAppendResponse, appendAssistantText, mergeIntermediateSteps, applyMessageUpdate, createAssistantMessage, updateAssistantMessage, shouldRenderAssistantMessage, extractConversationContent, } from '@/utils/chatTransform'; import { SystemResponseMessage, SystemIntermediateMessage, ErrorMessage, IntermediateStep, } from '@/types/websocket'; import { Message, Conversation } from '@/types/chat'; describe('chatTransform', () => { describe('shouldAppendResponse', () => { it('returns true for system_response_message with in_progress status and text content', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello world' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(true); }); it('returns false for system_response_message with complete status', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'complete', content: { text: 'Hello world' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(false); }); it('returns false for system_response_message with empty text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: '' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(false); }); it('returns false for system_response_message with whitespace-only text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: ' \n\t ' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(false); }); it('returns false for non-system_response_message types', () => { const message: ErrorMessage = { type: 'error', content: { text: 'Error occurred' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(false); }); }); describe('appendAssistantText', () => { it('concatenates to existing non-empty content', () => { const result = appendAssistantText('Hello', ' world'); expect(result).toBe('Hello world'); }); it('replaces empty content with new text', () => { const result = appendAssistantText('', 'Hello world'); expect(result).toBe('Hello world'); }); it('replaces FAIL placeholder with new text', () => { const result = appendAssistantText('FAIL', 'Hello world'); expect(result).toBe('Hello world'); }); it('returns previous content when new text is empty', () => { const result = appendAssistantText('Existing content', ''); expect(result).toBe('Existing content'); }); it('returns previous content when new text is whitespace only', () => { const result = appendAssistantText('Existing content', ' \n '); expect(result).toBe('Existing content'); }); it('handles null/undefined inputs gracefully', () => { // @ts-expect-error Testing runtime behavior const result = appendAssistantText(null, 'test'); expect(result).toBe('test'); }); }); describe('applyMessageUpdate', () => { const baseConversation: Conversation = { id: 'conv-1', name: 'New Conversation', messages: [], prompt: '', temperature: 0.7, folderId: null, }; it('updates conversation with new messages immutably', () => { const newMessages: Message[] = [ { role: 'user', content: 'Hello', id: 'msg-1' }, { role: 'assistant', content: 'Hi there', id: 'msg-2' }, ]; const result = applyMessageUpdate(baseConversation, newMessages); expect(result).not.toBe(baseConversation); // Immutability check expect(result.messages).toBe(newMessages); expect(result.id).toBe(baseConversation.id); }); it('updates conversation title from first user message', () => { const newMessages: Message[] = [ { role: 'user', content: 'What is the weather like today?', id: 'msg-1' }, { role: 'assistant', content: "It's sunny!", id: 'msg-2' }, ]; const result = applyMessageUpdate(baseConversation, newMessages); expect(result.name).toBe('What is the weather like today'); }); it('truncates long conversation titles to 30 characters', () => { const longMessage = 'This is a very long user message that should be truncated to 30 characters'; const newMessages: Message[] = [ { role: 'user', content: longMessage, id: 'msg-1' }, ]; const result = applyMessageUpdate(baseConversation, newMessages); expect(result.name).toBe(longMessage.substring(0, 30)); expect(result.name.length).toBe(30); }); it('does not update title if not "New Conversation"', () => { const conversationWithTitle = { ...baseConversation, name: 'Existing Title' }; const newMessages: Message[] = [ { role: 'user', content: 'New message', id: 'msg-1' }, ]; const result = applyMessageUpdate(conversationWithTitle, newMessages); expect(result.name).toBe('Existing Title'); }); it('does not update title if no user messages', () => { const newMessages: Message[] = [ { role: 'assistant', content: 'Hello', id: 'msg-1' }, ]; const result = applyMessageUpdate(baseConversation, newMessages); expect(result.name).toBe('New Conversation'); }); }); describe('createAssistantMessage', () => { it('creates assistant message with required fields', () => { const message = createAssistantMessage('msg-1', 'parent-1', 'Hello'); expect(message.role).toBe('assistant'); expect(message.id).toBe('msg-1'); expect(message.parentId).toBe('parent-1'); expect(message.content).toBe('Hello'); expect(message.intermediateSteps).toEqual([]); expect(message.humanInteractionMessages).toEqual([]); expect(message.errorMessages).toEqual([]); expect(typeof message.timestamp).toBe('number'); }); it('creates assistant message with optional arrays', () => { const steps: IntermediateStep[] = [{ id: 'step-1' }]; const interactions = [{ type: 'interaction' }]; const errors = [{ type: 'error' }]; const message = createAssistantMessage( 'msg-1', 'parent-1', 'Hello', steps, interactions, errors ); expect(message.intermediateSteps).toBe(steps); expect(message.humanInteractionMessages).toBe(interactions); expect(message.errorMessages).toBe(errors); }); it('defaults to empty content when not provided', () => { const message = createAssistantMessage('msg-1'); expect(message.content).toBe(''); expect(message.id).toBe('msg-1'); expect(message.parentId).toBeUndefined(); }); }); describe('updateAssistantMessage', () => { const baseMessage: Message = { role: 'assistant', content: 'Original content', id: 'msg-1', intermediateSteps: [], timestamp: 1000, }; it('updates content immutably', () => { const result = updateAssistantMessage(baseMessage, 'New content'); expect(result).not.toBe(baseMessage); // Immutability expect(result.content).toBe('New content'); expect(result.id).toBe(baseMessage.id); expect(result.timestamp).toBeGreaterThan(baseMessage.timestamp!); }); it('updates intermediate steps immutably', () => { const newSteps: IntermediateStep[] = [{ id: 'step-1' }]; const result = updateAssistantMessage(baseMessage, undefined, newSteps); expect(result.intermediateSteps).toBe(newSteps); expect(result.content).toBe(baseMessage.content); // Unchanged }); it('preserves original content when not provided', () => { const result = updateAssistantMessage(baseMessage); expect(result.content).toBe(baseMessage.content); expect(result.timestamp).toBeGreaterThan(baseMessage.timestamp!); }); it('handles empty content gracefully', () => { const messageWithEmptyContent = { ...baseMessage, content: '' }; const result = updateAssistantMessage(messageWithEmptyContent); expect(result.content).toBe(''); }); }); describe('shouldRenderAssistantMessage', () => { it('always renders non-assistant messages', () => { const userMessage: Message = { role: 'user', content: 'Hello', id: 'msg-1' }; expect(shouldRenderAssistantMessage(userMessage)).toBe(true); }); it('renders assistant messages with content', () => { const message: Message = { role: 'assistant', content: 'Hello', id: 'msg-1' }; expect(shouldRenderAssistantMessage(message)).toBe(true); }); it('renders assistant messages with intermediate steps', () => { const message: Message = { role: 'assistant', content: '', id: 'msg-1', intermediateSteps: [{ id: 'step-1' }], }; expect(shouldRenderAssistantMessage(message)).toBe(true); }); it('does not render assistant messages without content or steps', () => { const message: Message = { role: 'assistant', content: '', id: 'msg-1', intermediateSteps: [], }; expect(shouldRenderAssistantMessage(message)).toBe(false); }); it('does not render assistant messages with whitespace-only content', () => { const message: Message = { role: 'assistant', content: ' \n\t ', id: 'msg-1', intermediateSteps: [], }; expect(shouldRenderAssistantMessage(message)).toBe(false); }); }); describe('extractConversationContent', () => { it('extracts content from last message', () => { const conversation: Conversation = { id: 'conv-1', name: 'Test', messages: [ { role: 'user', content: 'Hello', id: 'msg-1' }, { role: 'assistant', content: 'Hi there', id: 'msg-2' }, ], prompt: '', temperature: 0.7, folderId: null, }; const result = extractConversationContent(conversation); expect(result).toBe('Hi there'); }); it('returns empty string for conversation with no messages', () => { const conversation: Conversation = { id: 'conv-1', name: 'Test', messages: [], prompt: '', temperature: 0.7, folderId: null, }; const result = extractConversationContent(conversation); expect(result).toBe(''); }); it('handles undefined content gracefully', () => { const conversation: Conversation = { id: 'conv-1', name: 'Test', messages: [{ role: 'user', content: undefined as any, id: 'msg-1' }], prompt: '', temperature: 0.7, folderId: null, }; const result = extractConversationContent(conversation); expect(result).toBe(''); }); }); }); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/docs/ui/README.md ================================================ # NeMo Agent Toolkit UI Documentation ## Overview This directory contains comprehensive documentation for the NeMo Agent toolkit UI, a React/Next.js application that provides a modern chat interface for AI agent interactions. ## Documentation Structure ### Feature Documentation - **[Chat Interface](./chat/chat-interface.md)** - Real-time conversational interface with streaming and voice input - **[Sidebar Navigation](./sidebar/conversation-management.md)** - Conversation organization, search, and folder management - **[Configuration Management](./settings/configuration-management.md)** - API configuration, import/export, and application settings - **[Button Reference](./button-reference.md)** - Comprehensive guide to all interactive buttons in the UI ### Component Documentation Each component directory contains a README.md with detailed behavior and integration information: - **[Chat Components](../../../../packages/nemo-agent-toolkit-ui/components/Chat/README.md)** - Core chat functionality and message handling - **[Chatbar Components](../../../../packages/nemo-agent-toolkit-ui/components/Chatbar/README.md)** - Conversation management and organization - **[Sidebar Components](../../../../packages/nemo-agent-toolkit-ui/components/Sidebar/README.md)** - Generic sidebar layout and controls - **[Folder Components](../../../../packages/nemo-agent-toolkit-ui/components/Folder/README.md)** - Collapsible organization containers ## Key Features - **Real-time Chat Streaming** via WebSocket connections and HTTP streaming - **Multiple API Endpoints** supporting both chat and generate modes (4 total: chat, chat/stream, generate, generate/stream) - **Human-in-the-Loop Workflows** with interactive modals and OAuth consent handling via new tabs - **Intermediate Steps Visualization** showing AI reasoning process - **Conversation Organization** with folders and search functionality - **Data Import/Export** for conversation backup and migration - **Voice Input/Output** with speech recognition and text-to-speech - **Dark/Light Theme** support with system detection - **Markdown Rendering** with syntax highlighting and custom components ## API Endpoints The application supports 4 distinct API endpoint modes: - **chat** - Standard chat completion (HTTP) - **chat/stream** - Streaming chat with SSE (HTTP) - **generate** - AI generation tasks (HTTP) - **generate/stream** - Streaming generation with intermediate steps (HTTP) ## WebSocket Message Types - **system_response_message** - Assistant responses with streaming content - **system_intermediate_message** - AI reasoning steps and workflow progress - **system_interaction_message** - Human-in-the-loop prompts and OAuth flows - **error** - Error handling and validation messages ## Tech Stack - **Framework:** Next.js 13+ with React 18 - **Language:** TypeScript for type safety - **Styling:** Tailwind CSS for responsive design - **State:** React Context + useReducer pattern - **Real-time:** WebSocket for streaming responses - **Markdown:** react-markdown with custom components - **Charts:** Recharts for data visualization - **Icons:** Tabler Icons for consistent iconography - **i18n:** next-i18next for internationalization ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/docs/ui/button-reference.md ================================================ # Button Reference ## Overview This document provides a comprehensive reference for all interactive buttons used throughout the NeMo Agent toolkit UI application. ## Chat Interface Buttons ### Message Input Area | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Voice Input** | `IconMicrophone` / `IconPlayerStopFilled` | Input field left | Start/stop voice-to-text recording | Always visible; disabled while streaming | | **File Upload** | `IconPaperclip` | Input field right | Upload files for chat context | Currently disabled (`fileUploadEnabled: false`); hidden while streaming | | **Send Message** | `IconSend` / Spinner | Input field right corner | Send user message | Always visible; shows spinner while streaming | | **Remove File** | `IconTrash` | File preview area | Remove uploaded file | Only when file is uploaded | ### Chat Control Buttons | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Stop Generating** | `IconPlayerStop` | Top center | Cancel ongoing response generation | Only visible while `messageIsStreaming` is true | | **Regenerate Response** | `IconRepeat` | Top center | Regenerate last assistant response | Only when not streaming and conversation has >1 messages | | **Scroll Down** | `IconArrowDown` | Bottom right | Scroll to bottom of chat | Only when `showScrollDownButton` is true | ### Message Action Buttons | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Copy Message** | `IconCopy` / `IconCheck` | Below assistant messages | Copy message content to clipboard | Not visible while streaming; shows check mark after copy | | **Text-to-Speech** | `IconVolume2` / `IconPlayerPause` | Below assistant messages | Play/pause message audio | Not visible while streaming; animates while playing | | **Edit Message** | `IconEdit` | User message hover | Enable inline message editing | Only on user messages | | **Delete Message** | `IconTrash` | User message hover | Delete message from conversation | Only on user messages | ## Sidebar Navigation Buttons ### Sidebar Controls | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Toggle Sidebar** | `IconMenu2` | Top left/right corner | Show/hide sidebar | Position changes based on sidebar state | | **New Chat** | `IconPlus` | Sidebar header | Create new conversation | Always visible in sidebar | | **New Folder** | `IconFolderPlus` | Sidebar header | Create new conversation folder | Always visible in sidebar | | **Clear Search** | `IconX` | Search input | Clear search filter | Only when search has content | ### Conversation Management | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Select Conversation** | None | Conversation list | Switch to conversation | Always visible for each conversation | | **Toggle Folder** | `IconChevronDown` / `IconChevronRight` | Folder header | Expand/collapse folder | Shows different icon based on folder state | ### Settings and Data Management | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Import Data** | `IconDownload` | Sidebar footer | Import conversation data from JSON | Always visible in sidebar footer | | **Export Data** | `IconFileExport` | Sidebar footer | Export all conversations to JSON | Always visible in sidebar footer | | **Clear Conversations** | `IconTrash` | Sidebar footer | Delete all conversations | Only visible when conversations exist | | **Settings** | `IconSettings` | Sidebar footer | Open application settings modal | Always visible in sidebar footer | ## Settings Modal Buttons ### Configuration Actions | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Save Settings** | None | Modal footer | Save configuration changes | Always visible in settings modal | | **Cancel Settings** | None | Modal footer | Close modal without saving | Always visible in settings modal | | **Test Connection** | None | API configuration section | Validate API endpoint connectivity | Always visible in settings modal | ## Human-in-the-Loop Interaction Buttons ### Interaction Modal | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Submit Text** | None | Interaction modal | Submit text input for workflow | When text input is required | | **Submit Choice** | None | Interaction modal | Submit selected option | When choice selection is required | | **Close Modal** | `IconX` | Modal header | Close interaction modal | Always visible in interaction modal | | **Choice Option** | None | Modal body | Select from multiple choices | When multiple choice interaction is required | ## Markdown Content Buttons ### Code Block Actions | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Copy Code** | `IconCopy` | Code block header | Copy code content to clipboard | Always visible on code blocks | | **Download Code** | `IconDownload` | Code block header | Download code as file | Always visible on code blocks | ### Image Interactions | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Toggle Fullscreen** | None | Image overlay | Enter/exit fullscreen view | Always visible on images | ### Chart Actions | Button | Icon | Location | Purpose | Visibility Conditions | |--------|------|----------|---------|----------------------| | **Download Chart** | `IconDownload` | Chart component | Download chart as image | Always visible on charts | ## Implementation Notes ### Button States - **Disabled**: Buttons are disabled during streaming or when actions are not applicable - **Loading**: Some buttons show spinner animations during processing - **Active**: Certain buttons have active states (e.g., recording, playing audio) ### Accessibility - All buttons include appropriate `aria-label` attributes for screen readers - Keyboard navigation is supported with Tab and Enter keys - Focus indicators are provided for keyboard users ### Styling Patterns - Primary actions use brand colors (`#76b900`) - Destructive actions use red colors for delete operations - Hover states provide visual feedback - Dark mode support with appropriate color variations ## Related Documentation - [Chat Interface](./chat/chat-interface.md) - Detailed chat functionality - [Sidebar Navigation](./sidebar/conversation-management.md) - Conversation management - [Configuration Management](./settings/configuration-management.md) - Settings and preferences ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/docs/ui/chat/chat-interface.md ================================================ # Chat Interface ## Purpose The chat interface provides real-time conversational interaction with AI agents through the NeMo Agent toolkit, supporting text input, voice input, and streaming responses with human-in-the-loop workflow capabilities. ## Scope - Route(s): `/` (main page) - Primary components: `Chat`, `ChatInput`, `ChatMessage`, `ChatHeader`, `ChatLoader`, `ChatInteractionMessage` - External deps: WebSocket for real-time streaming, HTTP API endpoints, speech recognition API, speech synthesis API, react-markdown for message rendering ## UI Elements | Element | Type | Location | Action/Handler | Notes | |--------|------|----------|----------------|-------| | Message Input | Textarea | Footer | onChange, onKeyDown | Auto-resizing, supports drag & drop | | Send Button | Button | Input Right | onSend | Disabled while streaming | | Stop Button | Button | Top Center | handleStopConversation | Only visible during streaming | | Regenerate Button | Button | Top Center | onRegenerate | Only visible after assistant response | | Voice Input | Button | Input Left | handleSpeechToText | Uses browser speech recognition | | Scroll Down | Button | Bottom Right | onScrollDownClick | Auto-hides when at bottom | | Message Actions | Buttons | Message Hover | Copy, Edit, Delete, Speak | Per-message actions | ## Component Tree ``` ├─ ├─
│ ├─ (for each message) │ │ ├─ (for assistant messages) │ │ ├─ (for user messages) │ │ └─ (message content) │ └─ (when loading) ├─ (for human-in-the-loop) └─ ├─ Voice Input Button ├─ Textarea └─ Send Button ``` ## Behavior **Message Processing:** - Dual-mode operation: WebSocket streaming and HTTP API calls - Support for 4 endpoint types: chat, chat/stream, generate, generate/stream - Real-time message streaming with character-by-character display - Intermediate steps visualization during AI processing - Human-in-the-loop workflow integration with interactive modals **Communication Modes:** - WebSocket mode for real-time bidirectional communication - HTTP streaming mode for server-sent events - Automatic fallback and reconnection handling - OAuth consent flow integration with new tab redirects **Message Features:** - Auto-scrolling to latest messages with manual scroll detection - Message editing, deletion, and regeneration capabilities - Copy-to-clipboard functionality for message content - Voice input via browser speech recognition API - Text-to-speech playback for accessibility - Markdown rendering with syntax highlighting ## Source Links - [components/Chat/Chat.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chat/Chat.tsx) - [components/Chat/ChatInput.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chat/ChatInput.tsx) - [components/Chat/ChatMessage.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chat/ChatMessage.tsx) - [components/Chat/ChatHeader.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chat/ChatHeader.tsx) - [components/Chat/ChatLoader.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chat/ChatLoader.tsx) - [components/Chat/ChatInteractionMessage.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chat/ChatInteractionMessage.tsx) ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/docs/ui/settings/configuration-management.md ================================================ # Configuration Management ## Purpose The settings system provides configuration management for API endpoints, WebSocket connections, conversation data import/export, and application preferences for the NeMo Agent toolkit UI. It supports both HTTP and WebSocket communication modes with predefined schemas for different endpoint types. ## Scope - Route(s): Modal dialog accessible from sidebar footer - Primary components: `SettingDialog`, `Import`, `ChatbarSettings` - External deps: Browser localStorage/sessionStorage, File API ## UI Elements | Element | Type | Location | Action/Handler | Notes | |--------|------|----------|----------------|-------| | Settings Button | Button | Sidebar Footer | Opens modal | Gear icon with tooltip | | API Endpoint Input | Input | Settings Modal | Configure base URL | HTTP chat completion endpoint | | WebSocket URL Input | Input | Settings Modal | Configure WS URL | Real-time streaming endpoint | | WebSocket Schema Select | Dropdown | Settings Modal | Select schema | Predefined schemas: chat_stream, chat, generate_stream, generate | | Intermediate Steps Toggle | Toggle | Settings Modal | Enable/disable | Show AI reasoning steps during processing | | Auto-scroll Toggle | Toggle | Settings Modal | Enable/disable | Automatic scrolling to latest messages | | Theme Toggle | Toggle | Settings Modal | Light/Dark mode | Persisted preference | | Import Button | Button | Settings Modal | File upload | JSON conversation import | | Export Button | Button | Settings Modal | Download file | Export all conversations | | Clear All Button | Button | Settings Modal | Reset data | Delete all conversations | | Test Connection | Button | Settings Modal | Validate config | Test API connectivity | ## Component Tree ``` ├─ Settings Button └─ (when open) ├─
│ ├─ API Configuration Section │ │ ├─ Chat Completion URL Input │ │ ├─ WebSocket URL Input │ │ └─ WebSocket Schema Select │ ├─ Feature Toggles Section │ │ ├─ Intermediate Steps Toggle │ │ ├─ Expand Details Toggle │ │ └─ Theme Toggle │ └─ Data Management Section │ ├─ │ ├─ Export Button │ └─ Clear Conversations Button └─ Modal Actions (Save/Cancel) ``` ## Behavior **Settings Modal:** - Accessible via gear icon in sidebar footer - Modal overlay with backdrop blur - Form validates inputs before saving - Changes persist to browser storage **API Configuration:** - Input fields for HTTP and WebSocket endpoints - Dropdown for predefined WebSocket schemas - Test connection button validates endpoints - Settings take effect immediately after save **Theme Management:** - Toggle between light and dark modes - Theme preference persisted in localStorage - Changes apply immediately to entire interface - System theme detection on first visit **Data Import/Export:** - Export button downloads conversations as JSON - Import accepts JSON files with validation - Clear all removes conversations with confirmation - Import replaces existing data completely ## Source Links - [components/Settings/SettingDialog.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Settings/SettingDialog.tsx) - [components/Settings/Import.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Settings/Import.tsx) - [components/Chatbar/components/ChatbarSettings.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chatbar/components/ChatbarSettings.tsx) ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/docs/ui/sidebar/conversation-management.md ================================================ # Sidebar Conversation Management ## Purpose The sidebar provides conversation navigation, organization through folders, and conversation management features including search, import/export, and settings access for the NeMo Agent toolkit UI. ## Scope - Route(s): Available on all pages (persistent sidebar) - Primary components: `Chatbar`, `Sidebar`, `Conversations`, `ChatFolders`, `ChatbarSettings` - External deps: Local storage for conversation persistence, drag & drop API ## UI Elements | Element | Type | Location | Action/Handler | Notes | |--------|------|----------|----------------|-------| | Toggle Sidebar | Button | Top Right | handleToggleChatbar | Shows/hides left sidebar | | New Chat | Button | Sidebar Header | handleNewConversation | Creates new conversation | | New Folder | Button | Sidebar Header | handleCreateFolder | Creates chat folder | | Search Input | Input | Sidebar Top | handleSearchTerm | Filters conversations by name/content | | Conversation Item | Button | Main Area | handleSelectConversation | Switches to conversation | | Folder | Collapsible | Main Area | toggleFolder | Organize conversations | | Settings | Button | Sidebar Footer | Opens settings modal | Configure API endpoints | | Import/Export | Buttons | Settings | Import/export data | JSON format conversation backup | | Clear All | Button | Settings | handleClearConversations | Removes all conversations | ## Component Tree ``` ├─ │ └─ │ ├─ (searchTerm handling) │ ├─
│ │ ├─ │ │ │ └─ (for each folder) │ │ │ └─ (conversations in folder) │ │ └─ │ │ └─ (unfiled conversations) │ └─ │ ├─ Import Button │ ├─ Export Button │ └─ Clear Conversations Button ``` ## Behavior **Conversation Management:** - New conversations appear at top of list - Clicking conversation switches active chat - Conversations persist in local storage - Search filters by conversation name and message content **Folder Organization:** - Drag conversations onto folders to organize - Folders can be created, renamed, and deleted - Conversations in folders are indented visually - Deleting folder moves conversations back to main list **Search Functionality:** - Real-time filtering as user types - Searches conversation names and message content - Clear button removes search filter - No results message when no matches found **Import/Export:** - Export downloads JSON file with all conversation data - Import accepts JSON files and replaces current data - Clear all removes all conversations with confirmation - Data persisted across browser sessions ## Source Links - [components/Chatbar/Chatbar.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chatbar/Chatbar.tsx) - [components/Chatbar/components/Conversations.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chatbar/components/Conversations.tsx) - [components/Chatbar/components/ChatFolders.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chatbar/components/ChatFolders.tsx) - [components/Chatbar/components/Conversation.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chatbar/components/Conversation.tsx) - [components/Chatbar/components/ChatbarSettings.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Chatbar/components/ChatbarSettings.tsx) - [components/Sidebar/Sidebar.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Sidebar/Sidebar.tsx) - [components/Folder/Folder.tsx](../../../../../packages/nemo-agent-toolkit-ui/components/Folder/Folder.tsx) ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/next-i18next.config.js ================================================ module.exports = { i18n: { defaultLocale: 'en', locales: [ 'bn', 'de', 'en', 'es', 'fr', 'he', 'id', 'it', 'ja', 'ko', 'pl', 'pt', 'ru', 'ro', 'sv', 'te', 'vi', 'zh', 'ar', 'tr', 'ca', 'fi', ], }, localePath: typeof window === 'undefined' ? require('path').resolve('../../node_modules/@nemo-agent-toolkit/ui/lib/public/locales') : '/public/locales', }; ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/next.config.js ================================================ const { configureRuntimeEnv } = require('next-runtime-env/build/configure'); const { i18n } = require('./next-i18next.config'); const nextConfig = { env: { ...configureRuntimeEnv(), }, i18n, output: 'standalone', typescript: { // !! WARN !! // Dangerously allow production builds to successfully complete even if // your project has type errors. // !! WARN !! ignoreBuildErrors: true, }, experimental: { serverActions: { bodySizeLimit: '5mb', }, }, webpack(config, { isServer, dev }) { config.experiments = { asyncWebAssembly: true, layers: true, }; return config; }, async redirects() { return []; }, }; module.exports = nextConfig; ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/package.json ================================================ { "name": "nemo-agent-toolkit-ui", "version": "0.1.1", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "typecheck": "tsc --noEmit", "clean": "rm -rf .next && rm -rf node_modules", "format": "prettier --write .", "lint": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx" }, "dependencies": { "@nemo-agent-toolkit/ui": "0.1.1", "next": "^15.0.8", "next-i18next": "^13.2.2", "next-runtime-env": "^1.3.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@types/node": "18.15.0", "@types/react": "18.0.28", "@types/react-dom": "18.0.11", "typescript": "4.9.5" } } ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/pages/_app.tsx ================================================ import { Toaster } from 'react-hot-toast'; import { QueryClient, QueryClientProvider } from 'react-query'; import { appWithTranslation } from 'next-i18next'; import type { AppProps } from 'next/app'; import { Inter } from 'next/font/google'; import '@nemo-agent-toolkit/ui/styles'; const inter = Inter({ subsets: ['latin'] }); function App({ Component, pageProps }: AppProps<{}>) { const queryClient = new QueryClient(); return (
); } export default appWithTranslation(App); ================================================ FILE: ui/apps/nemo-agent-toolkit-ui/pages/_document.tsx ================================================ import { DocumentProps, Head, Html, Main, NextScript } from 'next/document'; import i18nextConfig from '../next-i18next.config'; type Props = DocumentProps & { // add custom document props }; export default function Document(props: Props) { const currentLocale = props.__NEXT_DATA__.locale ?? i18nextConfig.i18n.defaultLocale; return ( ', 'data:image/svg+xml,', 'data:image/svg+xml,', // temorary, add to allowed list when we add support for SVG 'data:image/svg+xml,Hello World', 'file:///etc/passwd', 'ftp://evil.com/image.jpg' ]; dangerousUrls.forEach(url => { expect(isValidMediaURL(url)).toBe(false); }); }); it('blocks URLs with embedded credentials', () => { const credentialUrls = [ 'https://user:password@example.com/image.jpg', // pragma: allowlist secret 'http://admin:secret@cdn.com/video.mp4' // pragma: allowlist secret ]; credentialUrls.forEach(url => { expect(isValidMediaURL(url)).toBe(false); }); }); it('blocks reserved IP ranges to prevent SSRF', () => { const ssrfUrls = [ 'http://0.0.0.0/image.jpg', 'https://224.0.0.1/video.mp4', // Multicast 'http://240.1.1.1/media.png', // Multicast 'https://255.255.255.255/image.gif' // Broadcast ]; ssrfUrls.forEach(url => { expect(isValidMediaURL(url)).toBe(false); }); }); it('blocks malformed and empty URLs', () => { const invalidUrls = [ '', 'not-a-url', 'ht tp://broken.com/image.jpg', '://no-protocol.com/video.mp4', null, undefined, 123 ]; invalidUrls.forEach(url => { expect(isValidMediaURL(url as any)).toBe(false); }); }); it('blocks URLs with control characters', () => { const controlCharUrls = [ 'https://example.com\x00/image.jpg', 'https://example.com\x0a/video.mp4', 'https://example.com\x0d/media.png', 'https://example.com\x7f/image.gif' ]; controlCharUrls.forEach(url => { expect(isValidMediaURL(url)).toBe(false); }); }); }); }); // ============================================================================ // OAUTH URL VALIDATION // ============================================================================ describe('OAuth URL Validation', () => { describe('Positive Tests - Valid URLs should pass', () => { it('accepts valid HTTPS OAuth URLs', () => { const validUrls = [ 'https://accounts.google.com/oauth/authorize', 'https://login.microsoftonline.com/oauth2/authorize', 'https://github.com/login/oauth/authorize', 'https://api.example.com/oauth/consent' ]; validUrls.forEach(url => { expect(isValidConsentPromptURL(url)).toBe(true); }); }); it('accepts valid HTTP URLs', () => { expect(isValidConsentPromptURL('http://example.com/oauth')).toBe(true); }); }); describe('Negative Tests - Invalid URLs should be blocked', () => { it('blocks dangerous protocol schemes', () => { const dangerousUrls = [ 'javascript:alert("xss")', 'data:text/html,', 'vbscript:msgbox("xss")', 'file:///etc/passwd', 'ftp://evil.com/malware' ]; dangerousUrls.forEach(url => { expect(isValidConsentPromptURL(url)).toBe(false); }); }); it('blocks URLs with embedded credentials', () => { const credentialUrls = [ 'https://user:password@example.com/oauth', // pragma: allowlist secret 'http://admin:secret@malicious.com', // pragma: allowlist secret 'https://attacker:token@legitimate-site.com/oauth' // pragma: allowlist secret ]; credentialUrls.forEach(url => { expect(isValidConsentPromptURL(url)).toBe(false); }); }); it('blocks malformed URLs', () => { const malformedUrls = [ '', 'not-a-url', 'ht tp://broken.com', '://no-protocol.com', null, undefined ]; malformedUrls.forEach(url => { expect(isValidConsentPromptURL(url as any)).toBe(false); }); }); it('blocks URLs with control characters', () => { const controlCharUrls = [ 'https://example.com /oauth', // space character 'https://example.com\t/oauth', // tab character 'https://example.com\n/oauth', // newline character 'https://example.com\r/oauth' // carriage return ]; controlCharUrls.forEach(url => { expect(isValidConsentPromptURL(url)).toBe(false); }); }); }); }); // ============================================================================ // PATH NORMALIZATION TESTS // ============================================================================ describe('Path Normalization Tests', () => { describe('Path Traversal Prevention', () => { it('should block simple path traversal', () => { const result = validateProxyHttpPath('/api/../admin'); expect(result.isValid).toBe(false); }); it('should block encoded path traversal', () => { const result = validateProxyHttpPath('/api/%2E%2E/admin'); expect(result.isValid).toBe(false); }); it('should block double-encoded path traversal', () => { const result = validateProxyHttpPath('/api/%252E%252E%252Fadmin'); expect(result.isValid).toBe(false); }); it('should block complex traversal attempts', () => { const result = validateProxyHttpPath('/api/chat/../../admin'); expect(result.isValid).toBe(false); }); }); describe('Valid Paths', () => { it('should allow valid paths', () => { const result = validateProxyHttpPath('/api/chat/stream'); expect(result.isValid).toBe(true); }); it('should normalize and allow paths with dots', () => { const result = validateProxyHttpPath('/api/./chat/stream'); expect(result.isValid).toBe(true); }); it('should handle query parameters', () => { const result = validateProxyHttpPath('/api/chat/stream?session=123'); expect(result.isValid).toBe(true); }); }); describe('Edge Cases', () => { it('should reject empty path', () => { const result = validateProxyHttpPath(''); expect(result.isValid).toBe(false); }); it('should reject non-string input', () => { const result = validateProxyHttpPath(null as any); expect(result.isValid).toBe(false); }); }); }); }); ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/__tests__/types/websocket.test.ts ================================================ /** * Unit tests for WebSocket type guards and utility functions */ import { isSystemResponseMessage, isSystemResponseInProgress, isSystemResponseComplete, isSystemIntermediateMessage, isSystemInteractionMessage, isErrorMessage, isOAuthConsentMessage, validateWebSocketMessage, validateConversationId, validateWebSocketMessageWithConversationId, extractOAuthUrl, shouldAppendResponseContent, SystemResponseMessage, SystemIntermediateMessage, SystemInteractionMessage, ErrorMessage, } from '@/types/websocket'; describe('WebSocket Type Guards', () => { describe('isSystemResponseMessage', () => { it('returns true for valid system response message', () => { const message = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(isSystemResponseMessage(message)).toBe(true); }); it('returns false for other message types', () => { const message = { type: 'system_intermediate_message', content: { payload: 'data' }, }; expect(isSystemResponseMessage(message)).toBe(false); }); it('returns false for null/undefined', () => { expect(isSystemResponseMessage(null)).toBe(false); expect(isSystemResponseMessage(undefined)).toBe(false); }); }); describe('isSystemResponseInProgress', () => { it('returns true for in_progress system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(isSystemResponseInProgress(message)).toBe(true); }); it('returns false for complete system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'complete', content: { text: 'Hello' }, }; expect(isSystemResponseInProgress(message)).toBe(false); }); it('returns false for non-system response messages', () => { const message = { type: 'error_message', content: { message: 'Error' }, }; expect(isSystemResponseInProgress(message)).toBe(false); }); }); describe('isSystemResponseComplete', () => { it('returns true for complete system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'complete', }; expect(isSystemResponseComplete(message)).toBe(true); }); it('returns false for in_progress system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(isSystemResponseComplete(message)).toBe(false); }); }); describe('isSystemIntermediateMessage', () => { it('returns true for intermediate messages', () => { const message: SystemIntermediateMessage = { type: 'system_intermediate_message', content: { name: 'step', payload: 'data' }, }; expect(isSystemIntermediateMessage(message)).toBe(true); }); it('returns false for other message types', () => { const message = { type: 'system_response_message', status: 'complete', }; expect(isSystemIntermediateMessage(message)).toBe(false); }); }); describe('isSystemInteractionMessage', () => { it('returns true for interaction messages', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent' }, }; expect(isSystemInteractionMessage(message)).toBe(true); }); it('returns false for other message types', () => { const message = { type: 'error_message', content: { message: 'Error' }, }; expect(isSystemInteractionMessage(message)).toBe(false); }); }); describe('isErrorMessage', () => { it('returns true for error messages', () => { const message: ErrorMessage = { type: 'error', content: { text: 'Something went wrong', error: 'Details here' }, }; expect(isErrorMessage(message)).toBe(true); }); it('returns false for other message types', () => { const message = { type: 'system_response_message', status: 'complete', }; expect(isErrorMessage(message)).toBe(false); }); }); describe('isOAuthConsentMessage', () => { it('returns true for OAuth consent interaction messages', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.example.com', }, }; expect(isOAuthConsentMessage(message)).toBe(true); }); it('returns false for non-OAuth interaction messages', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'user_input' }, }; expect(isOAuthConsentMessage(message)).toBe(false); }); it('returns false for non-interaction messages', () => { const message = { type: 'error_message', content: { message: 'Error' }, }; expect(isOAuthConsentMessage(message)).toBe(false); }); }); describe('validateWebSocketMessage', () => { it('validates system response messages', () => { const message = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(validateWebSocketMessage(message)).toBe(true); }); it('validates intermediate messages', () => { const message = { type: 'system_intermediate_message', content: { payload: 'data' }, }; expect(validateWebSocketMessage(message)).toBe(true); }); it('validates interaction messages', () => { const message = { type: 'system_interaction_message', content: { input_type: 'oauth_consent' }, }; expect(validateWebSocketMessage(message)).toBe(true); }); it('validates error messages', () => { const message = { type: 'error', content: { text: 'Error occurred', error: 'Details' }, }; expect(validateWebSocketMessage(message)).toBe(true); }); it('rejects invalid message types', () => { const message = { type: 'invalid_type', content: { text: 'Hello' }, }; expect(validateWebSocketMessage(message)).toBe(false); }); it('rejects messages without type', () => { const message = { content: { text: 'Hello' }, }; expect(validateWebSocketMessage(message)).toBe(false); }); it('rejects null/undefined messages', () => { expect(validateWebSocketMessage(null)).toBe(false); expect(validateWebSocketMessage(undefined)).toBe(false); }); it('rejects non-object messages', () => { expect(validateWebSocketMessage('string')).toBe(false); expect(validateWebSocketMessage(123)).toBe(false); }); }); describe('extractOAuthUrl', () => { it('extracts oauth_url from OAuth consent message', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', oauth_url: 'https://oauth.example.com/auth', }, }; expect(extractOAuthUrl(message)).toBe('https://oauth.example.com/auth'); }); it('extracts redirect_url when oauth_url is not available', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', redirect_url: 'https://redirect.example.com', }, }; expect(extractOAuthUrl(message)).toBe('https://redirect.example.com'); }); it('extracts text when neither oauth_url nor redirect_url is available', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent', text: 'https://fallback.example.com', }, }; expect(extractOAuthUrl(message)).toBe('https://fallback.example.com'); }); it('returns null for non-OAuth consent messages', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'user_input' }, }; expect(extractOAuthUrl(message)).toBe(null); }); it('returns null when no URLs are available', () => { const message: SystemInteractionMessage = { type: 'system_interaction_message', content: { input_type: 'oauth_consent' }, }; expect(extractOAuthUrl(message)).toBe(null); }); }); describe('shouldAppendResponseContent', () => { it('returns true for in_progress system response with text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello world' }, }; expect(shouldAppendResponseContent(message)).toBe(true); }); it('returns false for complete system response', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'complete', content: { text: 'Hello world' }, }; expect(shouldAppendResponseContent(message)).toBe(false); }); it('returns false for system response without text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: {}, }; expect(shouldAppendResponseContent(message)).toBe(false); }); it('returns false for system response with empty text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: '' }, }; expect(shouldAppendResponseContent(message)).toBe(false); }); it('returns false for non-system response messages', () => { const message: ErrorMessage = { type: 'error', content: { text: 'Error occurred' }, }; expect(shouldAppendResponseContent(message)).toBe(false); }); }); describe('validateConversationId', () => { it('returns true for valid conversation ID', () => { const message = { conversation_id: 'valid-conversation-123', type: 'system_response_message', }; expect(validateConversationId(message)).toBe(true); }); it('returns false for null message', () => { expect(validateConversationId(null)).toBe(false); }); it('returns false for undefined message', () => { expect(validateConversationId(undefined)).toBe(false); }); it('returns false for non-object message', () => { expect(validateConversationId('string')).toBe(false); expect(validateConversationId(123)).toBe(false); expect(validateConversationId(true)).toBe(false); }); it('returns false for missing conversation_id', () => { const message = { type: 'system_response_message', status: 'in_progress', }; expect(validateConversationId(message)).toBe(false); }); it('returns false for non-string conversation_id', () => { const message = { conversation_id: 123, type: 'system_response_message', }; expect(validateConversationId(message)).toBe(false); }); it('returns false for empty string conversation_id', () => { const message = { conversation_id: '', type: 'system_response_message', }; expect(validateConversationId(message)).toBe(false); }); it('returns false for whitespace-only conversation_id', () => { const message = { conversation_id: ' \n\t ', type: 'system_response_message', }; expect(validateConversationId(message)).toBe(false); }); it('returns true for conversation_id with whitespace that has content', () => { const message = { conversation_id: ' valid-id ', type: 'system_response_message', }; expect(validateConversationId(message)).toBe(true); }); }); describe('validateWebSocketMessageWithConversationId', () => { const validMessage = { type: 'system_response_message', conversation_id: 'valid-conversation-123', status: 'in_progress', content: { text: 'Hello' }, }; it('returns true for valid message with conversation ID', () => { expect(validateWebSocketMessageWithConversationId(validMessage)).toBe(true); }); it('throws error for invalid message structure', () => { const invalidMessage = { type: 'invalid_type', conversation_id: 'valid-conversation-123', }; expect(() => validateWebSocketMessageWithConversationId(invalidMessage)) .toThrow('Invalid WebSocket message structure'); }); it('throws error for null message', () => { expect(() => validateWebSocketMessageWithConversationId(null)) .toThrow('Invalid WebSocket message structure'); }); it('throws error for undefined message', () => { expect(() => validateWebSocketMessageWithConversationId(undefined)) .toThrow('Invalid WebSocket message structure'); }); it('throws error for missing conversation_id', () => { const messageWithoutConversationId = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello' }, }; expect(() => validateWebSocketMessageWithConversationId(messageWithoutConversationId)) .toThrow('WebSocket message missing required conversation_id'); }); it('throws error for empty conversation_id', () => { const messageWithEmptyConversationId = { type: 'system_response_message', conversation_id: '', status: 'in_progress', content: { text: 'Hello' }, }; expect(() => validateWebSocketMessageWithConversationId(messageWithEmptyConversationId)) .toThrow('WebSocket message missing required conversation_id'); }); it('throws error for whitespace-only conversation_id', () => { const messageWithWhitespaceConversationId = { type: 'system_response_message', conversation_id: ' \n\t ', status: 'in_progress', content: { text: 'Hello' }, }; expect(() => validateWebSocketMessageWithConversationId(messageWithWhitespaceConversationId)) .toThrow('WebSocket message missing required conversation_id'); }); it('error message includes message type and conversation_id for debugging', () => { const messageWithoutConversationId = { type: 'system_intermediate_message', status: 'in_progress', content: { name: 'Step 1' }, }; try { validateWebSocketMessageWithConversationId(messageWithoutConversationId); fail('Expected error to be thrown'); } catch (error: any) { expect(error.message).toContain('system_intermediate_message'); expect(error.message).toContain('conversation_id'); } }); it('error message includes full message JSON for debugging', () => { const invalidMessage = { type: 'invalid_type', some_field: 'some_value', }; try { validateWebSocketMessageWithConversationId(invalidMessage); fail('Expected error to be thrown'); } catch (error: any) { expect(error.message).toContain(JSON.stringify(invalidMessage)); } }); it('validates all supported message types with conversation_id', () => { const messageTypes = [ 'system_response_message', 'system_intermediate_message', 'system_interaction_message', 'error' ]; messageTypes.forEach(type => { const message = { type, conversation_id: 'valid-conversation-123', status: 'in_progress', content: { text: 'Test' }, }; expect(validateWebSocketMessageWithConversationId(message)).toBe(true); }); }); }); }); ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/__tests__/utils/app/importExports.test.ts ================================================ import { cleanData, isExportFormatV1, isExportFormatV2, isExportFormatV3, isExportFormatV4, isLatestExportFormat, } from '@/utils/app/importExport'; import { ExportFormatV1, ExportFormatV2, ExportFormatV4 } from '@/types/export'; // Jest syntax - no need to import describe, expect, it describe('Export Format Functions', () => { describe('isExportFormatV1', () => { it('should return true for v1 format', () => { const obj = [{ id: 1 }]; expect(isExportFormatV1(obj)).toBe(true); }); it('should return false for non-v1 formats', () => { const obj = { version: 3, history: [], folders: [] }; expect(isExportFormatV1(obj)).toBe(false); }); }); describe('isExportFormatV2', () => { it('should return true for v2 format', () => { const obj = { history: [], folders: [] }; expect(isExportFormatV2(obj)).toBe(true); }); it('should return false for non-v2 formats', () => { const obj = { version: 3, history: [], folders: [] }; expect(isExportFormatV2(obj)).toBe(false); }); }); describe('isExportFormatV3', () => { it('should return true for v3 format', () => { const obj = { version: 3, history: [], folders: [] }; expect(isExportFormatV3(obj)).toBe(true); }); it('should return false for non-v3 formats', () => { const obj = { version: 4, history: [], folders: [] }; expect(isExportFormatV3(obj)).toBe(false); }); }); describe('isExportFormatV4', () => { it('should return true for v4 format', () => { const obj = { version: 4, history: [], folders: [], prompts: [] }; expect(isExportFormatV4(obj)).toBe(true); }); it('should return false for non-v4 formats', () => { const obj = { version: 5, history: [], folders: [], prompts: [] }; expect(isExportFormatV4(obj)).toBe(false); }); }); }); describe('cleanData Functions', () => { describe('cleaning v1 data', () => { it('should return the latest format', () => { const data = [ { id: 1, name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], }, ] as ExportFormatV1; const obj = cleanData(data); expect(isLatestExportFormat(obj)).toBe(true); expect(obj).toEqual({ version: 4, history: [ { id: 1, name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], folderId: null, }, ], folders: [], prompts: [], }); }); }); describe('cleaning v2 data', () => { it('should return the latest format', () => { const data = { history: [ { id: '1', name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], }, ], folders: [ { id: 1, name: 'folder 1', }, ], } as ExportFormatV2; const obj = cleanData(data); expect(isLatestExportFormat(obj)).toBe(true); expect(obj).toEqual({ version: 4, history: [ { id: '1', name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], folderId: null, }, ], folders: [ { id: '1', name: 'folder 1', type: 'chat', }, ], prompts: [], }); }); }); describe('cleaning v4 data', () => { it('should return the latest format', () => { const data = { version: 4, history: [ { id: '1', name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], folderId: null, }, ], folders: [ { id: '1', name: 'folder 1', type: 'chat', }, ], } as ExportFormatV4; const obj = cleanData(data); expect(isLatestExportFormat(obj)).toBe(true); expect(obj).toEqual({ version: 4, history: [ { id: '1', name: 'conversation 1', messages: [ { role: 'user', content: "what's up ?", }, { role: 'assistant', content: 'Hi', }, ], folderId: null, }, ], folders: [ { id: '1', name: 'folder 1', type: 'chat', }, ], }); }); }); }); ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/__tests__/utils/chatTransform.test.ts ================================================ /** * Unit tests for pure chat transformation helpers * These tests verify the core business logic without side effects */ import { shouldAppendResponse, appendAssistantText, mergeIntermediateSteps, applyMessageUpdate, createAssistantMessage, updateAssistantMessage, shouldRenderAssistantMessage, extractConversationContent, } from '@/utils/chatTransform'; import { SystemResponseMessage, SystemIntermediateMessage, ErrorMessage, IntermediateStep, } from '@/types/websocket'; import { Message, Conversation } from '@/types/chat'; describe('chatTransform', () => { describe('shouldAppendResponse', () => { it('returns true for system_response_message with in_progress status and text content', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: 'Hello world' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(true); }); it('returns false for system_response_message with complete status', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'complete', content: { text: 'Hello world' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(false); }); it('returns false for system_response_message with empty text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: '' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(false); }); it('returns false for system_response_message with whitespace-only text', () => { const message: SystemResponseMessage = { type: 'system_response_message', status: 'in_progress', content: { text: ' \n\t ' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(false); }); it('returns false for non-system_response_message types', () => { const message: ErrorMessage = { type: 'error', content: { text: 'Error occurred' }, conversation_id: 'test', }; expect(shouldAppendResponse(message)).toBe(false); }); }); describe('appendAssistantText', () => { it('concatenates to existing non-empty content', () => { const result = appendAssistantText('Hello', ' world'); expect(result).toBe('Hello world'); }); it('replaces empty content with new text', () => { const result = appendAssistantText('', 'Hello world'); expect(result).toBe('Hello world'); }); it('replaces FAIL placeholder with new text', () => { const result = appendAssistantText('FAIL', 'Hello world'); expect(result).toBe('Hello world'); }); it('returns previous content when new text is empty', () => { const result = appendAssistantText('Existing content', ''); expect(result).toBe('Existing content'); }); it('returns previous content when new text is whitespace only', () => { const result = appendAssistantText('Existing content', ' \n '); expect(result).toBe('Existing content'); }); it('handles null/undefined inputs gracefully', () => { // @ts-expect-error Testing runtime behavior const result = appendAssistantText(null, 'test'); expect(result).toBe('test'); }); }); describe('applyMessageUpdate', () => { const baseConversation: Conversation = { id: 'conv-1', name: 'New Conversation', messages: [], temperature: 0.7, folderId: null, }; it('updates conversation with new messages immutably', () => { const newMessages: Message[] = [ { role: 'user', content: 'Hello', id: 'msg-1' }, { role: 'assistant', content: 'Hi there', id: 'msg-2' }, ]; const result = applyMessageUpdate(baseConversation, newMessages); expect(result).not.toBe(baseConversation); // Immutability check expect(result.messages).toBe(newMessages); expect(result.id).toBe(baseConversation.id); }); it('updates conversation title from first user message', () => { const newMessages: Message[] = [ { role: 'user', content: 'What is the weather like today?', id: 'msg-1' }, { role: 'assistant', content: "It's sunny!", id: 'msg-2' }, ]; const result = applyMessageUpdate(baseConversation, newMessages); expect(result.name).toBe('What is the weather like today'); }); it('truncates long conversation titles to 30 characters', () => { const longMessage = 'This is a very long user message that should be truncated to 30 characters'; const newMessages: Message[] = [ { role: 'user', content: longMessage, id: 'msg-1' }, ]; const result = applyMessageUpdate(baseConversation, newMessages); expect(result.name).toBe(longMessage.substring(0, 30)); expect(result.name.length).toBe(30); }); it('does not update title if not "New Conversation"', () => { const conversationWithTitle = { ...baseConversation, name: 'Existing Title' }; const newMessages: Message[] = [ { role: 'user', content: 'New message', id: 'msg-1' }, ]; const result = applyMessageUpdate(conversationWithTitle, newMessages); expect(result.name).toBe('Existing Title'); }); it('does not update title if no user messages', () => { const newMessages: Message[] = [ { role: 'assistant', content: 'Hello', id: 'msg-1' }, ]; const result = applyMessageUpdate(baseConversation, newMessages); expect(result.name).toBe('New Conversation'); }); }); describe('createAssistantMessage', () => { it('creates assistant message with required fields', () => { const message = createAssistantMessage('msg-1', 'parent-1', 'Hello'); expect(message.role).toBe('assistant'); expect(message.id).toBe('msg-1'); expect(message.parentId).toBe('parent-1'); expect(message.content).toBe('Hello'); expect(message.intermediateSteps).toEqual([]); expect(message.humanInteractionMessages).toEqual([]); expect(message.errorMessages).toEqual([]); expect(typeof message.timestamp).toBe('number'); }); it('creates assistant message with optional arrays', () => { const steps: IntermediateStep[] = [{ id: 'step-1' }]; const interactions = [{ type: 'interaction' }]; const errors = [{ type: 'error' }]; const message = createAssistantMessage( 'msg-1', 'parent-1', 'Hello', steps, interactions, errors ); expect(message.intermediateSteps).toBe(steps); expect(message.humanInteractionMessages).toBe(interactions); expect(message.errorMessages).toBe(errors); }); it('defaults to empty content when not provided', () => { const message = createAssistantMessage('msg-1'); expect(message.content).toBe(''); expect(message.id).toBe('msg-1'); expect(message.parentId).toBeUndefined(); }); }); describe('updateAssistantMessage', () => { const baseMessage: Message = { role: 'assistant', content: 'Original content', id: 'msg-1', intermediateSteps: [], timestamp: 1000, }; it('updates content immutably', () => { const result = updateAssistantMessage(baseMessage, 'New content'); expect(result).not.toBe(baseMessage); // Immutability expect(result.content).toBe('New content'); expect(result.id).toBe(baseMessage.id); expect(result.timestamp).toBeGreaterThan(baseMessage.timestamp!); }); it('updates intermediate steps immutably', () => { const newSteps: IntermediateStep[] = [{ id: 'step-1' }]; const result = updateAssistantMessage(baseMessage, undefined, newSteps); expect(result.intermediateSteps).toBe(newSteps); expect(result.content).toBe(baseMessage.content); // Unchanged }); it('preserves original content when not provided', () => { const result = updateAssistantMessage(baseMessage); expect(result.content).toBe(baseMessage.content); expect(result.timestamp).toBeGreaterThan(baseMessage.timestamp!); }); it('handles empty content gracefully', () => { const messageWithEmptyContent = { ...baseMessage, content: '' }; const result = updateAssistantMessage(messageWithEmptyContent); expect(result.content).toBe(''); }); it('preserves existing observabilityTraceId', () => { const messageWithTraceId = { ...baseMessage, observabilityTraceId: 'trace-123' }; const result = updateAssistantMessage(messageWithTraceId, 'New content'); expect(result.observabilityTraceId).toBe('trace-123'); expect(result.content).toBe('New content'); }); it('preserves undefined observabilityTraceId', () => { const result = updateAssistantMessage(baseMessage, 'New content'); expect(result.observabilityTraceId).toBeUndefined(); expect(result.content).toBe('New content'); }); it('preserves observabilityTraceId when updating intermediate steps', () => { const messageWithTraceId = { ...baseMessage, observabilityTraceId: 'trace-123' }; const newSteps: IntermediateStep[] = [{ id: 'step-1' }]; const result = updateAssistantMessage(messageWithTraceId, 'Updated content', newSteps); expect(result.content).toBe('Updated content'); expect(result.intermediateSteps).toBe(newSteps); expect(result.observabilityTraceId).toBe('trace-123'); expect(result.timestamp).toBeGreaterThan(baseMessage.timestamp!); }); }); describe('shouldRenderAssistantMessage', () => { it('always renders non-assistant messages', () => { const userMessage: Message = { role: 'user', content: 'Hello', id: 'msg-1' }; expect(shouldRenderAssistantMessage(userMessage)).toBe(true); }); it('renders assistant messages with content', () => { const message: Message = { role: 'assistant', content: 'Hello', id: 'msg-1' }; expect(shouldRenderAssistantMessage(message)).toBe(true); }); it('renders assistant messages with intermediate steps', () => { const message: Message = { role: 'assistant', content: '', id: 'msg-1', intermediateSteps: [{ id: 'step-1' }], }; expect(shouldRenderAssistantMessage(message)).toBe(true); }); it('does not render assistant messages without content or steps', () => { const message: Message = { role: 'assistant', content: '', id: 'msg-1', intermediateSteps: [], }; expect(shouldRenderAssistantMessage(message)).toBe(false); }); it('does not render assistant messages with whitespace-only content', () => { const message: Message = { role: 'assistant', content: ' \n\t ', id: 'msg-1', intermediateSteps: [], }; expect(shouldRenderAssistantMessage(message)).toBe(false); }); }); describe('extractConversationContent', () => { it('extracts content from last message', () => { const conversation: Conversation = { id: 'conv-1', name: 'Test', messages: [ { role: 'user', content: 'Hello', id: 'msg-1' }, { role: 'assistant', content: 'Hi there', id: 'msg-2' }, ], temperature: 0.7, folderId: null, }; const result = extractConversationContent(conversation); expect(result).toBe('Hi there'); }); it('returns empty string for conversation with no messages', () => { const conversation: Conversation = { id: 'conv-1', name: 'Test', messages: [], temperature: 0.7, folderId: null, }; const result = extractConversationContent(conversation); expect(result).toBe(''); }); it('handles undefined content gracefully', () => { const conversation: Conversation = { id: 'conv-1', name: 'Test', messages: [{ role: 'user', content: undefined as any, id: 'msg-1' }], temperature: 0.7, folderId: null, }; const result = extractConversationContent(conversation); expect(result).toBe(''); }); }); }); ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Avatar/AgentAvatar.tsx ================================================ import { IconUserPentagon } from '@tabler/icons-react'; import React from 'react'; export const AgentAvatar = ({ height = 7, width = 7 }) => { return (
); }; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Avatar/BotAvatar.tsx ================================================ import React from 'react'; export const BotAvatar = ({ height = 30, width = 30, src = '' }) => { const onError = (event: { target: { src: string } }) => { console.error('error loading bot avatar'); event.target.src = `nvidia.jpg`; }; return ( bot-avatar ); }; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Avatar/SystemAvatar.tsx ================================================ import { IconPasswordUser, IconUserPentagon } from '@tabler/icons-react'; import React from 'react'; export const SystemAgentAvatar = ({ height = 7, width = 7 }) => { return (
); }; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Avatar/UserAvatar.tsx ================================================ import React from 'react'; import { getInitials } from '@/utils/app/helper'; export const UserAvatar = ({ src = '', height = 30, width = 30 }) => { const profilePicUrl = src || ``; const onError = (event: { target: { src: string } }) => { const svg = ` user `; event.target.src = `data:image/svg+xml;base64,${window.btoa(svg)}`; }; return ( {'user-avatar'} ); }; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Buttons/SidebarActionButton/SidebarActionButton.tsx ================================================ import { MouseEventHandler, ReactElement } from 'react'; interface Props { handleClick: MouseEventHandler; children: ReactElement; } const SidebarActionButton = ({ handleClick, children }: Props) => ( ); export default SidebarActionButton; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Buttons/SidebarActionButton/index.ts ================================================ export { default } from './SidebarActionButton'; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Chat/Chat.tsx ================================================ 'use client'; import { ChatHeader } from './ChatHeader'; import { ChatInput } from './ChatInput'; import { ChatLoader } from './ChatLoader'; import { MemoizedChatMessage } from './MemoizedChatMessage'; import { CustomAgentParamsValues } from './CustomAgentParams'; import { InteractionModal } from '@/components/Chat/ChatInteractionMessage'; import HomeContext from '@/pages/api/home/home.context'; import { ChatBody, Conversation, Message, CustomAgentParams } from '@/types/chat'; import { WebSocketInbound, validateWebSocketMessage, validateWebSocketMessageWithConversationId, validateConversationId, isSystemResponseMessage, isSystemIntermediateMessage, isSystemInteractionMessage, isErrorMessage, isSystemResponseInProgress, isSystemResponseComplete, isOAuthConsentMessage, extractOAuthUrl, shouldAppendResponseContent, } from '@/types/websocket'; import { getEndpoint } from '@/utils/app/api'; import { webSocketMessageTypes } from '@/utils/app/const'; import { saveConversation, saveConversations, updateConversation, } from '@/utils/app/conversation'; import { fetchLastMessage, processIntermediateMessage, updateConversationTitle, } from '@/utils/app/helper'; import { shouldAppendResponse, appendAssistantText, mergeIntermediateSteps, applyMessageUpdate, createAssistantMessage, updateAssistantMessage, shouldRenderAssistantMessage, } from '@/utils/chatTransform'; import { throttle } from '@/utils/data/throttle'; import { useTranslation } from 'next-i18next'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import toast from 'react-hot-toast'; import { v4 as uuidv4 } from 'uuid'; import { SESSION_COOKIE_NAME } from '@/constants/constants'; // Streaming utilities for handling SSE and NDJSON safely function normalizeNewlines(s: string): string { // turn CRLF into LF so splitting is predictable return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } function extractSsePayloads(buffer: string): { frames: string[]; rest: string; } { buffer = normalizeNewlines(buffer); // Split on blank line (event delimiter) const parts = buffer.split(/\n\n/); const rest = parts.pop() ?? ''; const frames: string[] = []; for (const block of parts) { // Keep only lines that start with "data:" possibly followed by a space const dataLines = block .split('\n') .filter(line => /^data:\s*/.test(line)) .map(line => line.replace(/^data:\s*/, '').trim()) .filter(line => line.length > 0); if (dataLines.length === 0) continue; // Some servers send multi-line JSON; join them const payload = dataLines.join('\n'); // Ignore sentinel frames if (payload === '[DONE]' || payload === 'DONE') continue; frames.push(payload); } return { frames, rest }; } function splitNdjson(buffer: string): { lines: string[]; rest: string } { buffer = normalizeNewlines(buffer); const parts = buffer.split('\n'); const rest = parts.pop() ?? ''; // strip empty/whitespace lines const lines = parts.map(l => l.trim()).filter(Boolean); return { lines, rest }; } function tryParseJson(s: string): T | null { try { return JSON.parse(s); } catch { return null; } } function parsePossiblyConcatenatedJson(payload: string): any[] { // Fast path const single = tryParseJson(payload); if (single !== null) return [single]; // Slow path: try to split concatenated top-level objects const objs: any[] = []; let depth = 0, start = -1; for (let i = 0; i < payload.length; i++) { const ch = payload[i]; if (ch === '{') { if (depth === 0) start = i; depth++; } else if (ch === '}') { depth--; if (depth === 0 && start !== -1) { const slice = payload.slice(start, i + 1); const parsed = tryParseJson(slice); if (parsed !== null) objs.push(parsed); start = -1; } } } return objs; } // Debug helper for streaming parse issues (commented out for production) // const debugParse = (label: string, payload: string) => { // const preview = payload.length > 200 ? payload.slice(0, 200) + '…' : payload; // console.debug(`[stream][${label}] payload preview:`, preview); // }; export const Chat = () => { const { t } = useTranslation('chat'); const { state: { selectedConversation, conversations, messageIsStreaming, loading, chatHistory, webSocketConnected, webSocketMode, webSocketURL, webSocketSchema, chatCompletionURL, expandIntermediateSteps, intermediateStepOverride, enableIntermediateSteps, chatMessageEditEnabled, chatMessageSpeakerEnabled, chatMessageCopyEnabled, interactionModalCancelEnabled, }, storageKeyPrefix, handleUpdateConversation, dispatch: homeDispatch, onAnswerComplete, onAnswerCompleteWithContent, onSubmitMessageReady, onMessageSubmitted, } = useContext(HomeContext); const [currentMessage, setCurrentMessage] = useState(); const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); const [showSettings, setShowSettings] = useState(false); const [showScrollDownButton, setShowScrollDownButton] = useState(false); const messagesEndRef = useRef(null); const chatContainerRef = useRef(null); const textareaRef = useRef(null); const controllerRef = useRef(new AbortController()); const selectedConversationRef = useRef(selectedConversation); const conversationsRef = useRef(conversations); const [modalOpen, setModalOpen] = useState(false); const [interactionMessage, setInteractionMessage] = useState(null); const webSocketRef = useRef(null); const webSocketConnectedRef = useRef(false); // Initialize with state value, will be properly set in useEffect after checking sessionStorage const webSocketModeRef = useRef(webSocketMode); let websocketLoadingToastId: string | null = null; // Sync webSocketModeRef with sessionStorage and state changes // This runs on mount and whenever webSocketMode state changes useEffect(() => { if (typeof window !== 'undefined') { const storedValue = sessionStorage.getItem('webSocketMode'); if (storedValue !== null) { // User has a stored preference from toggling - respect it webSocketModeRef.current = storedValue === 'true'; } else { // No stored preference - use the env-based default from state webSocketModeRef.current = webSocketMode ?? false; } } }, [webSocketMode]); const lastScrollTop = useRef(0); // Store last known scroll position // Add these variables near the top of your component const isUserInitiatedScroll = useRef(false); const scrollTimeout = useRef(null); // WebSocket message tracking for stop generating functionality const activeUserMessageId = useRef(null); // WebSocket throttling for state updates - reduces render cycles while maintaining smooth streaming const WS_THROTTLE_MS = 32; // ~30fps update rate const wsLastDispatchTime = useRef(0); const wsPendingUpdate = useRef<{ conversationId: string; messages: Message[]; } | null>(null); const wsFlushTimeout = useRef(null); // Store custom agent params for use in handleSend const customAgentParamsRef = useRef(null); // Ref to store the latest handleSend function for stable callbacks // This prevents unnecessary re-renders of memoized chat messages const handleSendRef = useRef<(message: Message, deleteCount?: number, retry?: boolean) => Promise>(); /** * Handles stopping conversation generation for WebSocket mode * Marks the current active user message as stopped and resets UI state */ const handleStopConversation = useCallback(() => { if (webSocketModeRef?.current) { console.log('Stopping generation for user message:', activeUserMessageId.current); // Set active user message ID to null to ignore subsequent messages activeUserMessageId.current = null; // Reset UI state homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); } else { // HTTP mode - use the existing abort controller logic try { controllerRef?.current?.abort('aborted'); setTimeout(() => { controllerRef.current = new AbortController(); // Reset the controller }, 100); } catch (error) { console.log('error aborting - ', error); } } }, [webSocketModeRef, homeDispatch]); const openModal = (data: any = {}) => { setInteractionMessage(data); setModalOpen(true); // Notify embedder onAnswerComplete?.(); }; const handleUserInteraction = ({ interactionMessage = {}, userResponse = '', }: any) => { // todo send user input to websocket server as user response to interaction message // console.log("User response:", userResponse); const wsMessage = { type: webSocketMessageTypes.userInteractionMessage, id: uuidv4(), //new id for every new message thread_id: interactionMessage?.thread_id, // same thread_id from interaction message received parent_id: interactionMessage?.parent_id, // same parent_id from interaction message received content: { messages: [ { role: 'user', content: [ { type: 'text', text: userResponse, }, ], }, ], }, timestamp: new Date().toISOString(), }; webSocketRef?.current?.send(JSON.stringify(wsMessage)); }; useEffect(() => { selectedConversationRef.current = selectedConversation; }, [selectedConversation]); // Keep conversationsRef in sync with state so that "Clear conversations" and other // state-driven changes are reflected. Skip syncing while streaming so we don't // overwrite the ref with stale state (streaming handlers update the ref directly). useEffect(() => { if (!messageIsStreaming) { conversationsRef.current = conversations; } }, [conversations, messageIsStreaming]); // Reset WebSocket state when conversation changes to prevent stale message display useEffect(() => { if (selectedConversation?.id) { // Clear any pending WebSocket message tracking activeUserMessageId.current = null; // Clear WebSocket throttling state wsLastDispatchTime.current = 0; wsPendingUpdate.current = null; if (wsFlushTimeout.current) { clearTimeout(wsFlushTimeout.current); wsFlushTimeout.current = null; } // Clear streaming states to ensure clean conversation switch homeDispatch({ field: 'messageIsStreaming', value: false }); homeDispatch({ field: 'loading', value: false }); } }, [selectedConversation?.id]); useEffect(() => { if (webSocketModeRef?.current && !webSocketConnectedRef.current) { connectWebSocket(); } // todo cancel ongoing connection attempts else { if (websocketLoadingToastId) toast.dismiss(websocketLoadingToastId); } return () => { if (webSocketRef?.current) { webSocketRef?.current?.close(); webSocketConnectedRef.current = false; } }; // Use webSocketMode state instead of ref in dependencies - refs don't trigger re-renders }, [webSocketMode, webSocketURL]); const connectWebSocket = async (retryCount = 0) => { const maxRetries = 3; const retryDelay = 1000; // 1-second delay between retries if (!(sessionStorage.getItem('webSocketURL') || webSocketURL)) { toast.error('Please set a valid WebSocket server in settings'); return false; } return new Promise(resolve => { // Universal cookie handling for both cross-origin and same-origin connections const getCookie = (name: string) => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift(); return null; }; const sessionCookie = getCookie(SESSION_COOKIE_NAME); let wsUrl: string = sessionStorage.getItem('webSocketURL') || webSocketURL || 'ws://127.0.0.1:8000/websocket'; // Determine if this is a cross-origin connection const wsUrlObj = new URL(wsUrl); const isCrossOrigin = wsUrlObj.origin !== window.location.origin; // Always add session cookie as query parameter for reliability // This works for both cross-origin (required) and same-origin (redundant but harmless) if (sessionCookie) { const separator = wsUrl.includes('?') ? '&' : '?'; wsUrl += `${separator}session=${encodeURIComponent(sessionCookie)}`; } else { } const ws = new WebSocket(wsUrl); websocketLoadingToastId = toast.loading( 'WebSocket is not connected, trying to connect...', { id: 'websocketLoadingToastId' } ); ws.onopen = () => { toast.success( 'Connected to ' + (sessionStorage.getItem('webSocketURL') || webSocketURL), { id: 'websocketSuccessToastId', } ); if (websocketLoadingToastId) toast.dismiss(websocketLoadingToastId); // using ref due to usecallback for handlesend which will be recreated during next render when dependency array changes // so values inside of are still one and be updated after next render // so we'll not see any changes to websocket (state variable) or webSocketConnected (context variable) changes while the function is executing webSocketConnectedRef.current = true; homeDispatch({ field: 'webSocketConnected', value: true }); webSocketRef.current = ws; resolve(true); // Resolve true only when connected }; ws.onmessage = event => { const message = JSON.parse(event.data); handleWebSocketMessage(message); }; ws.onclose = async () => { if (retryCount < maxRetries) { retryCount++; // Retry and capture the result if (webSocketModeRef?.current) { // Wait for retry delay await new Promise(res => setTimeout(res, retryDelay)); const success = await connectWebSocket(retryCount); resolve(success); } } else { // Only resolve(false) after all retries fail homeDispatch({ field: 'webSocketConnected', value: false }); webSocketConnectedRef.current = false; homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); if (websocketLoadingToastId) toast.dismiss(websocketLoadingToastId); toast.error('WebSocket connection failed.', { id: 'websocketErrorToastId', }); resolve(false); } }; ws.onerror = error => { homeDispatch({ field: 'webSocketConnected', value: false }); webSocketConnectedRef.current = false; homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); ws.close(); // Ensure the WebSocket is closed on error }; }); }; // Re-attach the WebSocket handler when intermediateStepOverride changes because we need updated value from settings useEffect(() => { if (webSocketRef.current) { webSocketRef.current.onmessage = event => { const message = JSON.parse(event.data); handleWebSocketMessage(message); }; } }, [intermediateStepOverride]); /** * Handles OAuth consent flow by opening popup window */ const handleOAuthConsent = (message: WebSocketInbound) => { if (!isSystemInteractionMessage(message)) return false; if (message.content?.input_type === 'oauth_consent') { const oauthUrl = extractOAuthUrl(message); if (oauthUrl) { const popup = window.open( oauthUrl, 'oauth-popup', 'width=600,height=700,scrollbars=yes,resizable=yes' ); const handleOAuthComplete = (event: MessageEvent) => { if (popup && !popup.closed) popup.close(); window.removeEventListener('message', handleOAuthComplete); }; window.addEventListener('message', handleOAuthComplete); } return true; } return false; }; /** * Updates refs immediately before React dispatch to prevent stale reads */ const updateRefsAndDispatch = ( updatedConversations: Conversation[], updatedConversation: Conversation, currentSelectedConversation: Conversation | null | undefined ) => { // Write-through to refs before dispatch to avoid stale reads on next WS tick conversationsRef.current = updatedConversations; if (currentSelectedConversation?.id === updatedConversation.id) { selectedConversationRef.current = updatedConversation; } // Dispatch and persist homeDispatch({ field: 'conversations', value: updatedConversations }); saveConversations(updatedConversations, storageKeyPrefix); if (currentSelectedConversation?.id === updatedConversation.id) { homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); saveConversation(updatedConversation, storageKeyPrefix); } }; /** * Processes system response messages for content updates * Only appends content for in_progress status with non-empty text */ const processSystemResponseMessage = ( message: WebSocketInbound, messages: Message[] ): Message[] => { if (!shouldAppendResponse(message)) { return messages; } const incomingText = isSystemResponseMessage(message) ? message.content?.text || '' : ''; const lastMessage = messages.at(-1); const isLastAssistant = lastMessage?.role === 'assistant'; if (isLastAssistant) { // Append to existing assistant message using pure helper const combinedContent = appendAssistantText( lastMessage.content || '', incomingText ); return messages.map((m, idx) => idx === messages.length - 1 ? updateAssistantMessage(m, combinedContent) : m ); } else { // Create new assistant message using pure helper return [ ...messages, createAssistantMessage(message.id, message.parent_id, incomingText), ]; } }; /** * Processes intermediate step messages without modifying content */ const processIntermediateStepMessage = ( message: WebSocketInbound, messages: Message[] ): Message[] => { if (!isSystemIntermediateMessage(message)) return messages; const lastMessage = messages.at(-1); const isLastAssistant = lastMessage?.role === 'assistant'; if (!isLastAssistant) { // Create new assistant message with empty content for intermediate steps const stepWithIndex = { ...message, index: 0 }; return [ ...messages, createAssistantMessage(message.id, message.parent_id, '', [ stepWithIndex, ]), ]; } else { // Update intermediate steps on existing assistant message using pure helper const lastIdx = messages.length - 1; const lastSteps = messages[lastIdx]?.intermediateSteps || []; const mergedSteps = mergeIntermediateSteps( lastSteps, message, sessionStorage.getItem('intermediateStepOverride') === 'false' ? false : Boolean(intermediateStepOverride) ); return messages.map((m, idx) => idx === lastIdx ? updateAssistantMessage(m, m.content, mergedSteps) : m ); } }; /** * Processes error messages by attaching them to assistant messages */ const processErrorMessage = ( message: WebSocketInbound, messages: Message[] ): Message[] => { if (!isErrorMessage(message)) return messages; const lastMessage = messages.at(-1); const isLastAssistant = lastMessage?.role === 'assistant'; if (isLastAssistant) { // Attach error to existing assistant message return messages.map((m, idx) => idx === messages.length - 1 ? { ...m, errorMessages: [...(m.errorMessages || []), message], timestamp: Date.now(), } : m ); } else { // Create new assistant message for error using pure helper return [ ...messages, createAssistantMessage( message.id, message.parent_id, '', [], [], [message] ), ]; } }; /** * Flushes pending WebSocket updates to state */ const flushWsPendingUpdate = () => { if (!wsPendingUpdate.current) return; const { conversationId, messages } = wsPendingUpdate.current; const currentConversations = conversationsRef.current; const targetConversation = currentConversations.find( c => c.id === conversationId ); if (!targetConversation) { wsPendingUpdate.current = null; return; } // Update conversation with pending messages const updatedConversation = applyMessageUpdate( targetConversation, messages ); // Update conversations array const updatedConversations = currentConversations.map(c => c.id === updatedConversation.id ? updatedConversation : c ); // Update state and persistence updateRefsAndDispatch( updatedConversations, updatedConversation, selectedConversationRef.current ); wsPendingUpdate.current = null; wsLastDispatchTime.current = Date.now(); }; /** * Main WebSocket message handler * Processes different message types and updates conversation state * Uses throttling to reduce render cycles (~30fps) */ const handleWebSocketMessage = (message: any) => { // Validate message structure AND conversation ID with detailed error reporting try { validateWebSocketMessageWithConversationId(message); } catch (error: any) { console.error('WebSocket message validation failed:', error.message); toast.error(`WebSocket Error: ${error.message}`); // Log additional debugging info console.error('Raw message data:', message); console.error( 'Available conversations:', conversationsRef.current?.map(c => ({ id: c.id, name: c.name })) ); return; // Don't process invalid messages } // Filter messages based on active conversation for stop generating functionality const messageConversationId = message.conversation_id; const currentConversationId = selectedConversationRef.current?.id; if (activeUserMessageId.current === null || messageConversationId !== currentConversationId) { return; } // End loading indicators as messages arrive homeDispatch({ field: 'loading', value: false }); // Check if this is a completion message const isComplete = isSystemResponseComplete(message); if (isComplete) { // Flush any pending updates immediately before completing if (wsFlushTimeout.current) { clearTimeout(wsFlushTimeout.current); wsFlushTimeout.current = null; } flushWsPendingUpdate(); setTimeout(() => { homeDispatch({ field: 'messageIsStreaming', value: false }); // Clear active tracking when response is complete activeUserMessageId.current = null; const conv = selectedConversationRef.current; const lastMsg = conv?.messages?.slice(-1)[0]; if (lastMsg?.role === 'assistant' && typeof lastMsg.content === 'string') { onAnswerComplete?.(); onAnswerCompleteWithContent?.(lastMsg.content); } }, 200); return; } // Handle human-in-the-loop interactions using type guard if (isSystemInteractionMessage(message)) { // Check for OAuth consent message and automatically open OAuth URL directly if (message?.content?.input_type === 'oauth_consent') { // Expect the OAuth URL to be directly in the message content const oauthUrl = message?.content?.oauth_url || message?.content?.redirect_url || message?.content?.text; if (oauthUrl) { // Open the OAuth URL directly in a new tab window.open(oauthUrl, '_blank'); } else { console.error( 'OAuth consent message received but no URL found in content:', message?.content ); toast.error('OAuth URL not found in message content'); } return; // Don't process further or show modal } openModal(message); return; } // Respect intermediate-steps toggle if ( sessionStorage.getItem('enableIntermediateSteps') === 'false' && isSystemIntermediateMessage(message) ) { return; } // Find target conversation with enhanced error reporting const currentConversations = conversationsRef.current; const targetConversation = currentConversations.find( c => c.id === message.conversation_id ); if (!targetConversation) { return; } // Process message based on type using pure helpers // Use pending messages as base if available for same conversation, otherwise use target conversation const pending = wsPendingUpdate.current; let baseMessages = (pending && pending.conversationId === message.conversation_id) ? pending.messages : targetConversation.messages; let updatedMessages = baseMessages; updatedMessages = processSystemResponseMessage(message, updatedMessages); updatedMessages = processIntermediateStepMessage(message, updatedMessages); updatedMessages = processErrorMessage(message, updatedMessages); // Force string materialization on the last message content to prevent race conditions const lastMsg = updatedMessages.at(-1); if (lastMsg?.content) { void lastMsg.content.length; } // Store pending update wsPendingUpdate.current = { conversationId: message.conversation_id, messages: updatedMessages, }; // Determine if we should dispatch now (throttled updates) const now = Date.now(); const timeSinceLastDispatch = now - wsLastDispatchTime.current; const isFirstMessage = wsLastDispatchTime.current === 0 || baseMessages === targetConversation.messages; if (isFirstMessage || timeSinceLastDispatch >= WS_THROTTLE_MS) { // Dispatch immediately if (wsFlushTimeout.current) { clearTimeout(wsFlushTimeout.current); wsFlushTimeout.current = null; } flushWsPendingUpdate(); } else { // Schedule a flush if not already scheduled if (!wsFlushTimeout.current) { wsFlushTimeout.current = setTimeout(() => { wsFlushTimeout.current = null; flushWsPendingUpdate(); }, WS_THROTTLE_MS - timeSinceLastDispatch); } } }; const handleSend = useCallback( async (message: Message, deleteCount = 0, retry = false) => { // DON'T mutate the original message - create a new one with a new ID const messageWithNewId = { ...message, id: uuidv4() }; // Set the active user message ID for WebSocket message tracking activeUserMessageId.current = messageWithNewId.id; // Notify embedder that a message was submitted (e.g. so Search tab can disable content until response completes) onMessageSubmitted?.(); // chat with bot if (selectedConversation) { let updatedConversation: Conversation; if (deleteCount) { const updatedMessages = [...selectedConversation.messages]; for (let i = 0; i < deleteCount; i++) { updatedMessages.pop(); } updatedConversation = { ...selectedConversation, messages: [...updatedMessages, messageWithNewId], }; } else { // remove content from attachment since it could a large base64 encoded string which can cause session stroage overflow // Clone the message and update the attachment contentconst updateMessage = JSON.parse(JSON.stringify(message)); const updateMessage = JSON.parse(JSON.stringify(messageWithNewId)); if (updateMessage?.attachment) { updateMessage.attachment.content = ''; } updatedConversation = { ...selectedConversation, messages: [...selectedConversation.messages, { ...updateMessage }], // Remove isHomepageConversation flag when first message is sent to make it visible in sidebar isHomepageConversation: undefined, }; } homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); homeDispatch({ field: 'loading', value: true }); homeDispatch({ field: 'messageIsStreaming', value: true }); // websocket connection chat request if (webSocketModeRef?.current) { if (!webSocketConnectedRef?.current) { const connected = await connectWebSocket(); if (!connected) { homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); toast.error('Failed to send message. WebSocket connection could not be established.'); return; } else { handleSend(messageWithNewId, 1); return; } } toast.dismiss(); saveConversation(updatedConversation, storageKeyPrefix); // Use conversationsRef.current to avoid stale closure that causes conversation wiping const currentConversations = conversationsRef.current || []; const conversationExists = currentConversations.some( c => c.id === selectedConversation.id ); let updatedConversations: Conversation[]; if (conversationExists) { // Update existing conversation updatedConversations = currentConversations.map(conversation => { if (conversation.id === selectedConversation.id) { return updatedConversation; } return conversation; }); } else { // Add new conversation if it doesn't exist in the array updatedConversations = [...currentConversations, updatedConversation]; } // Update the ref immediately to prevent race condition with incoming WebSocket messages conversationsRef.current = updatedConversations; homeDispatch({ field: 'conversations', value: updatedConversations, }); saveConversations(updatedConversations, storageKeyPrefix); let chatMessages; if (chatHistory) { chatMessages = updatedConversation?.messages?.map( (message: Message) => { return { role: message.role, content: [ { type: 'text', text: message?.content?.trim() || '', }, ...(typeof message?.content === 'object' && message?.content && 'attachments' in message.content && (message.content as any).attachments?.length > 0 ? (message.content as any).attachments?.map( (attachment: any) => ({ type: 'image', image_url: attachment?.content, }) ) : []), ], }; } ); } // else set only the user last message else { chatMessages = [ updatedConversation?.messages[ updatedConversation?.messages?.length - 1 ], ].map(message => { return { role: message.role, content: [ { type: 'text', text: message?.content?.trim() || '', }, ], }; }); } const wsMessage = { // Spread custom params first so fixed fields take precedence ...(customAgentParamsRef.current || {}), type: webSocketMessageTypes.userMessage, schema_type: sessionStorage.getItem('webSocketSchema') || webSocketSchema, id: messageWithNewId?.id, conversation_id: selectedConversation.id, content: { messages: chatMessages, }, timestamp: new Date().toISOString(), }; // console.log('Sent message via websocket', wsMessage) webSocketRef?.current?.send(JSON.stringify(wsMessage)); return; } // cleaning up messages to fit the request payload const messagesCleaned = updatedConversation.messages.map(message => { return { role: message.role, content: (typeof message.content === 'string' ? message.content : '' ).trim(), }; }); const chatBody: ChatBody = { // Spread custom params first so fixed fields take precedence ...(customAgentParamsRef.current || {}), messages: chatHistory ? messagesCleaned : [{ role: 'user', content: message?.content }], chatCompletionURL: sessionStorage.getItem('chatCompletionURL') || chatCompletionURL, additionalProps: { enableIntermediateSteps: sessionStorage.getItem( 'enableIntermediateSteps' ) ? sessionStorage.getItem('enableIntermediateSteps') === 'true' : enableIntermediateSteps, }, }; const endpoint = getEndpoint({ service: 'chat' }); let body; body = JSON.stringify({ ...chatBody, }); let response; try { response = await fetch(`${window.location.origin}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Conversation-Id': selectedConversation?.id || '', 'User-Message-ID': messageWithNewId?.id || '', }, signal: controllerRef.current.signal, // Use ref here body, }); if (!response?.ok) { homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); toast.error(response.statusText); return; } const data = response?.body; if (!data) { homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); toast.error('Error: No data received from server'); return; } if (!false) { if (updatedConversation.messages.length === 1) { const { content } = message; const customName = content.length > 30 ? content.substring(0, 30) + '...' : content; updatedConversation = { ...updatedConversation, name: customName, }; } homeDispatch({ field: 'loading', value: false }); const reader = data.getReader(); const decoder = new TextDecoder(); let done = false; let isFirst = true; let text = ''; let counter = 1; let partialIntermediateStep = ''; // Add this to store partial chunks // Throttling for state updates - reduces render cycles while maintaining smooth streaming const THROTTLE_MS = 32; // ~30fps update rate let lastDispatchTime = 0; let pendingIntermediateSteps: any[] = []; // Accumulate intermediate steps between dispatches let hasPendingUpdate = false; // Initialize streaming buffers const currentURL = sessionStorage.getItem('chatCompletionURL') || chatCompletionURL || ''; const isGenerateStream = currentURL.includes('generate'); let sseBuffer = ''; let ndjsonBuffer = ''; while (!done) { const { value, done: doneReading } = await reader.read(); done = doneReading; if (!value) continue; let chunkValue = ''; // Handle generate/stream endpoints safely if (isGenerateStream) { const chunkText = decoder.decode(value, { stream: true }); // 1) Try SSE first sseBuffer += chunkText; let sseFrames: string[] = []; ({ frames: sseFrames, rest: sseBuffer } = extractSsePayloads(sseBuffer)); let extractedText = ''; if (sseFrames.length > 0) { for (const frame of sseFrames) { const objs = parsePossiblyConcatenatedJson(frame); for (const obj of objs) { if (obj && typeof obj.value === 'string') { extractedText += obj.value; } else if (typeof obj === 'string') { extractedText += obj; // some servers may send string payloads } } } } else { // 2) Fall back to NDJSON if it wasn't SSE ndjsonBuffer += chunkText; let lines: string[] = []; ({ lines, rest: ndjsonBuffer } = splitNdjson(ndjsonBuffer)); for (const line of lines) { const obj = tryParseJson(line); if (obj && typeof obj.value === 'string') { extractedText += obj.value; } else if (typeof obj === 'string') { extractedText += obj; } } } // 3) If neither SSE nor NDJSON detected, treat as plain text chunkValue = extractedText || chunkText; } else { // Non-generate streaming path (existing logic) chunkValue = decoder.decode(value, { stream: true }); } // Ensure chunkValue is always a string if (typeof chunkValue !== 'string') { chunkValue = String(chunkValue ?? ''); } counter++; // First, handle any partial chunk from previous iteration if (partialIntermediateStep) { chunkValue = partialIntermediateStep + chunkValue; partialIntermediateStep = ''; } // Check for incomplete tags const openingTagIndex = chunkValue.lastIndexOf(''); const closingTagIndex = chunkValue.lastIndexOf( '' ); // If we have an opening tag without a closing tag (or closing tag comes before opening) if (openingTagIndex > closingTagIndex) { // Store the partial chunk for the next iteration partialIntermediateStep = chunkValue.substring(openingTagIndex); // Remove the partial chunk from current processing chunkValue = chunkValue.substring(0, openingTagIndex); } // Process complete intermediate steps let rawIntermediateSteps: any[] = []; let stepMatches = chunkValue.match( /([\s\S]*?)<\/intermediatestep>/g ) || []; for (const stepMatch of stepMatches) { try { const jsonString = stepMatch .replace('', '') .replace('', '') .trim(); let rawIntermediateMessage = tryParseJson(jsonString); // handle intermediate data if (rawIntermediateMessage?.type === 'system_intermediate') { rawIntermediateSteps.push(rawIntermediateMessage); } } catch (error) { // console.log('Stream response parse error:', error.message); } } // if the received chunk contains rawIntermediateSteps then remove them from the chunkValue if (stepMatches.length > 0) { chunkValue = chunkValue.replace( /[\s\S]*?<\/intermediatestep>/g, '' ); } text = text + chunkValue; // Force string materialization to prevent race condition with state updates // This ensures the string is fully evaluated before being passed to dispatch void text.length; // Accumulate intermediate steps for batched dispatch pendingIntermediateSteps.push(...rawIntermediateSteps); hasPendingUpdate = true; // Determine if we should dispatch now (throttled updates) const now = Date.now(); const shouldDispatch = isFirst || done || (now - lastDispatchTime >= THROTTLE_MS); if (shouldDispatch && hasPendingUpdate) { lastDispatchTime = now; hasPendingUpdate = false; homeDispatch({ field: 'loading', value: false }); if (isFirst) { isFirst = false; // loop through pendingIntermediateSteps and add them to the processedIntermediateSteps let processedIntermediateSteps: any[] = []; pendingIntermediateSteps.forEach(step => { processedIntermediateSteps = processIntermediateMessage( processedIntermediateSteps, step, sessionStorage.getItem('intermediateStepOverride') === 'false' ? false : intermediateStepOverride ); }); pendingIntermediateSteps = []; // Clear after processing // update the message const updatedMessages: Message[] = [ ...updatedConversation.messages, { role: 'assistant', content: text, // main response content without intermediate steps intermediateSteps: [...processedIntermediateSteps], // intermediate steps }, ]; updatedConversation = { ...updatedConversation, messages: updatedMessages, }; homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); } else { const updatedMessages: Message[] = updatedConversation.messages.map((message, index) => { if (index === updatedConversation.messages.length - 1) { // process intermediate steps // need to loop through pendingIntermediateSteps and add them to the updatedIntermediateSteps let updatedIntermediateSteps = Array.isArray( message?.intermediateSteps ) ? [...message.intermediateSteps] : []; pendingIntermediateSteps.forEach(step => { updatedIntermediateSteps = processIntermediateMessage( updatedIntermediateSteps, step, sessionStorage.getItem('intermediateStepOverride') === 'false' ? false : intermediateStepOverride ); }); // update the message const msg = { ...message, content: text, // main response content intermediateSteps: updatedIntermediateSteps, // intermediate steps }; return msg; } return message; }); pendingIntermediateSteps = []; // Clear after processing updatedConversation = { ...updatedConversation, messages: updatedMessages, }; homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); } } } // Final dispatch if there's any pending update after loop ends if (hasPendingUpdate) { const updatedMessages: Message[] = updatedConversation.messages.map((message, index) => { if (index === updatedConversation.messages.length - 1) { let updatedIntermediateSteps = Array.isArray( message?.intermediateSteps ) ? [...message.intermediateSteps] : []; pendingIntermediateSteps.forEach(step => { updatedIntermediateSteps = processIntermediateMessage( updatedIntermediateSteps, step, sessionStorage.getItem('intermediateStepOverride') === 'false' ? false : intermediateStepOverride ); }); return { ...message, content: text, intermediateSteps: updatedIntermediateSteps, }; } return message; }); updatedConversation = { ...updatedConversation, messages: updatedMessages, }; homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); } console.log(`[STREAMING] HTTP response complete detected at: ${new Date().toISOString()} (${performance.now().toFixed(2)}ms)`); saveConversation(updatedConversation, storageKeyPrefix); const updatedConversations: Conversation[] = conversations.map( conversation => { if (conversation.id === selectedConversation.id) { return updatedConversation; } return conversation; } ); if (updatedConversations.length === 0) { updatedConversations.push(updatedConversation); } homeDispatch({ field: 'conversations', value: updatedConversations, }); saveConversations(updatedConversations, storageKeyPrefix); // to show the message on UI and scroll to the bottom after 200ms delay setTimeout(() => { homeDispatch({ field: 'messageIsStreaming', value: false }); homeDispatch({ field: 'loading', value: false }); onAnswerComplete?.(); onAnswerCompleteWithContent?.(text); }, 200); } else { const { answer } = await response?.json(); const updatedMessages: Message[] = [ ...updatedConversation.messages, { role: 'assistant', content: answer }, ]; updatedConversation = { ...updatedConversation, messages: updatedMessages, }; homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); saveConversation(updatedConversation, storageKeyPrefix); const updatedConversations: Conversation[] = conversations.map( conversation => { if (conversation.id === selectedConversation.id) { return updatedConversation; } return conversation; } ); if (updatedConversations.length === 0) { updatedConversations.push(updatedConversation); } homeDispatch({ field: 'conversations', value: updatedConversations, }); saveConversations(updatedConversations, storageKeyPrefix); homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); onAnswerComplete?.(); onAnswerCompleteWithContent?.(answer); } } catch (error) { saveConversation(updatedConversation, storageKeyPrefix); homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); if (error === 'aborted' || (error as any)?.name === 'AbortError') { return; } else { return; } } } }, [ conversations, selectedConversation, homeDispatch, chatHistory, webSocketConnected, webSocketSchema, chatCompletionURL, expandIntermediateSteps, intermediateStepOverride, enableIntermediateSteps, storageKeyPrefix, onAnswerComplete, onAnswerCompleteWithContent, onMessageSubmitted, ] ); // Keep handleSendRef updated with the latest handleSend function // This allows handleEditMessage to remain stable while still calling the latest version useEffect(() => { handleSendRef.current = handleSend; }, [handleSend]); // Expose programmatic submit to embedder (send a message to the agent without user typing) useEffect(() => { if (!onSubmitMessageReady || !selectedConversation) return; const submitMessage = (content: string) => { const message: Message = { role: 'user', content }; handleSendRef.current?.(message, 0); onMessageSubmitted?.(); }; onSubmitMessageReady(submitMessage); }, [onSubmitMessageReady, selectedConversation?.id, onMessageSubmitted]); // Create stable onEdit callback to prevent unnecessary re-renders of MemoizedChatMessage // Uses ref to access the latest handleSend without depending on it directly const handleEditMessage = useCallback((editedMessage: Message, deleteCount?: number) => { setCurrentMessage(editedMessage); handleSendRef.current?.(editedMessage, deleteCount || 0); }, []); // Empty deps - stable reference forever // Create stable onDelete callback - uses refs to access latest state const handleDeleteMessage = useCallback((messageIndex: number) => { const conversation = selectedConversationRef.current; const allConversations = conversationsRef.current; if (!conversation) return; const { messages } = conversation; if (messageIndex < 0 || messageIndex >= messages.length) return; // Create a copy of messages to avoid mutating state directly const updatedMessages = [...messages]; // If next message is assistant response, delete both if ( messageIndex < updatedMessages.length - 1 && updatedMessages[messageIndex + 1].role === 'assistant' ) { updatedMessages.splice(messageIndex, 2); } else { updatedMessages.splice(messageIndex, 1); } const updatedConversation = { ...conversation, messages: updatedMessages, }; const { single, all } = updateConversation( updatedConversation, allConversations || [], storageKeyPrefix, ); // Update refs immediately to prevent stale state selectedConversationRef.current = single; conversationsRef.current = all; homeDispatch({ field: 'selectedConversation', value: single }); homeDispatch({ field: 'conversations', value: all }); }, [storageKeyPrefix, homeDispatch]); // Refs for latest state; prefix for correct storage // Track previous streaming state to detect completion and ensure embedder is notified const prevStreamingRef = useRef(messageIsStreaming); useEffect(() => { const wasStreaming = prevStreamingRef.current; prevStreamingRef.current = messageIsStreaming; if (messageIsStreaming) { setAutoScrollEnabled(true); setShowScrollDownButton(false); homeDispatch({ field: 'autoScroll', value: true }); } else { // When streaming stops, disable auto-scroll to prevent further automatic scrolling setAutoScrollEnabled(false); homeDispatch({ field: 'autoScroll', value: false }); // Fallback: when streaming just ended, notify embedder so attention signal is not missed if (wasStreaming && (onAnswerComplete || onAnswerCompleteWithContent)) { const conv = selectedConversationRef.current; const lastMsg = conv?.messages?.slice(-1)[0]; if (lastMsg?.role === 'assistant' && typeof lastMsg.content === 'string') { onAnswerComplete?.(); onAnswerCompleteWithContent?.(lastMsg.content); } } } }, [messageIsStreaming, onAnswerComplete, onAnswerCompleteWithContent]); // Add an effect to set up wheel and touchmove event listeners useEffect(() => { const container = chatContainerRef.current; if (!container) return; // Function to handle user input events (mouse wheel, touch) const handleUserInput = () => { // Mark this as user-initiated scrolling isUserInitiatedScroll.current = true; // Reset the flag after a longer delay to ensure scroll event is captured if (scrollTimeout.current) { clearTimeout(scrollTimeout.current); } scrollTimeout.current = setTimeout(() => { isUserInitiatedScroll.current = false; }, 500) as NodeJS.Timeout; }; // Add event listeners for user interactions container.addEventListener('wheel', handleUserInput, { passive: true }); container.addEventListener('touchmove', handleUserInput, { passive: true }); return () => { // Clean up container.removeEventListener('wheel', handleUserInput); container.removeEventListener('touchmove', handleUserInput); if (scrollTimeout.current) { clearTimeout(scrollTimeout.current); } }; }, [chatContainerRef.current]); // Only re-run if the container ref changes // Now modify your handleScroll function to use this flag const handleScroll = useCallback(() => { if (!chatContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current; const isScrollingUp = scrollTop < lastScrollTop.current; const isAtBottom = scrollHeight - scrollTop - clientHeight < 20; // Disable auto-scroll on any scroll-up during streaming (mouse wheel up or scrollbar drag up) if (isScrollingUp && autoScrollEnabled && messageIsStreaming) { setAutoScrollEnabled(false); homeDispatch({ field: 'autoScroll', value: false }); setShowScrollDownButton(true); } // Re-enable auto-scroll if user scrolls to bottom if (isAtBottom && !autoScrollEnabled) { setAutoScrollEnabled(true); homeDispatch({ field: 'autoScroll', value: true }); setShowScrollDownButton(false); } lastScrollTop.current = scrollTop; }, [autoScrollEnabled, messageIsStreaming]); const handleScrollDown = () => { chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: 'smooth', }); // Enable auto-scroll after user clicks scroll down, assuming the user wants to auto-scroll setAutoScrollEnabled(true); homeDispatch({ field: 'autoScroll', value: true }); }; const scrollDown = () => { if (autoScrollEnabled) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end', }); } }; const throttledScrollDown = throttle(scrollDown, 250); useEffect(() => { throttledScrollDown(); selectedConversation && setCurrentMessage(() => { const len = selectedConversation?.messages.length ?? 0; return len >= 2 ? selectedConversation.messages[len - 2] : undefined; }); }, [selectedConversation]); useEffect(() => { // Only set up the observer if we're actually streaming if (!messageIsStreaming) { return; } const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { textareaRef.current?.focus(); } // Only auto-scroll if we're streaming and auto-scroll is enabled if (autoScrollEnabled && messageIsStreaming) { requestAnimationFrame(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end', }); }); } }, { root: null, threshold: 0.5, } ); const messagesEndElement = messagesEndRef.current; if (messagesEndElement) { observer.observe(messagesEndElement); } return () => { if (messagesEndElement) { observer.unobserve(messagesEndElement); } }; }, [autoScrollEnabled, messageIsStreaming]); return (
<>
handleSend(message, 0)} /> {selectedConversation?.messages.map((message, index, arr) => { // Hide hidden messages (used for auto-generated prompts like video upload) if (message.hidden) { return null; } if (!shouldRenderAssistantMessage(message)) { return null; // Hide empty assistant messages } // Only pass isStreaming to the last assistant message when actively streaming const isLastMessage = index === arr.length - 1; const isStreamingMessage = messageIsStreaming && isLastMessage && message.role === 'assistant'; return ( ); })} {loading && }
{ setCurrentMessage(message); if (customParams) { customAgentParamsRef.current = customParams; } handleSend(message, 0); }} onScrollDownClick={handleScrollDown} onRegenerate={() => { if (currentMessage && currentMessage?.role === 'user') { handleSend(currentMessage, 0); } else { const lastUserMessage = fetchLastMessage({ messages: selectedConversation?.messages || [], role: 'user', }); lastUserMessage && handleSend(lastUserMessage, 1); } }} showScrollDownButton={showScrollDownButton} controller={controllerRef} onStopConversation={handleStopConversation} /> setModalOpen(false)} onSubmit={handleUserInteraction} showCancelButton={interactionModalCancelEnabled} />
); }; Chat.displayName = 'Chat'; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Chat/ChatFileUpload.tsx ================================================ import { useRef, useState, useCallback, useContext, useMemo, useEffect } from 'react'; import toast from 'react-hot-toast'; import { IconVideoPlus, IconX, IconFileCode, IconCheck, IconChevronDown, IconCopy, IconPlus, IconVideo } from '@tabler/icons-react'; import HomeContext from '@/pages/api/home/home.context'; import { copyToClipboard } from '@/utils/shared/clipboard'; import { uploadFile, type FileUploadResult } from '@/utils/shared/videoUpload'; // Types for upload file config template export type UploadFileFieldType = 'boolean' | 'string' | 'number' | 'array' | 'select'; export interface UploadFileFieldConfig { 'field-name': string; 'field-type': UploadFileFieldType; 'field-default-value': boolean | string | number | string[] | number[]; 'field-options'?: string[] | number[]; 'changeable'?: boolean; 'tooltip-info'?: string; } // Interface for upload file config template export interface UploadFileConfigTemplate { fields: UploadFileFieldConfig[]; } // Upload status for each file type FileUploadStatus = 'pending' | 'uploading' | 'success' | 'error' | 'cancelled'; // Interface for file with form data interface FileWithFormData { id: string; file: File; formData: Record; isExpanded: boolean; metadataFile?: File | null; isMetadataExpanded?: boolean; uploadProgress?: number; uploadStatus?: FileUploadStatus; uploadError?: string; } // CSS class constants const INPUT_CLASS = 'w-full rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 focus:border-[#76b900] focus:outline-none focus:ring-1 focus:ring-[#76b900] dark:border-gray-600 dark:bg-[#343541] dark:text-gray-300'; const POPUP_OVERLAY_CLASS = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50'; const POPUP_CONTAINER_CLASS = 'mx-4 w-full max-w-xl rounded-lg bg-white p-6 shadow-xl dark:bg-[#343541]'; interface ChatFileUploadProps { /** Callback when upload completes successfully */ onUploadSuccess?: (result: FileUploadResult) => void; /** Callback when upload fails */ onUploadError?: (error: Error) => void; /** Callback to send a hidden message after video upload completes */ onSendHiddenMessage?: (message: string) => void; /** Whether upload is disabled */ disabled?: boolean; /** Accepted file types (default: video/mp4) */ accept?: string; children: (props: { triggerUpload: () => void; triggerFilePicker: () => void; isUploading: boolean; uploadProgress: number; isDragging: boolean; dragHandlers: { onDragEnter: (e: React.DragEvent) => void; onDragLeave: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent) => void; }; }) => React.ReactNode; } export const ChatFileUpload: React.FC = ({ onUploadSuccess, onUploadError, onSendHiddenMessage, disabled = false, accept = '.mp4,.mkv,video/mp4,video/x-matroska', children, }) => { const { state: { agentApiUrlBase, chatUploadFileConfigTemplateJson, chatUploadFileMetadataEnabled, chatUploadFileHiddenMessageTemplate }, } = useContext(HomeContext); const videoInputRef = useRef(null); const metadataInputRef = useRef(null); const [pendingMetadataFileId, setPendingMetadataFileId] = useState(null); const [isUploading, setIsUploading] = useState(false); const [showSuccessPopup, setShowSuccessPopup] = useState(false); const [showProgressPopup, setShowProgressPopup] = useState(false); const [allUploadResults, setAllUploadResults] = useState<{ filename: string; result?: FileUploadResult; error?: string; cancelled?: boolean }[]>([]); const [uploadingFiles, setUploadingFiles] = useState([]); const [expandedResults, setExpandedResults] = useState>(new Set()); const [copiedResultIndex, setCopiedResultIndex] = useState(null); const [isDragging, setIsDragging] = useState(false); const dragCounterRef = useRef(0); // Store AbortControllers for each file to enable cancellation const abortControllerMapRef = useRef>(new Map()); // Track cancelled file IDs to prevent upload after cancellation const cancelledFileIdsRef = useRef>(new Set()); // File selection popup state const [showFileSelectPopup, setShowFileSelectPopup] = useState(false); const [selectedFiles, setSelectedFiles] = useState([]); // Drag states for drop zones in popup const [isDraggingMedia, setIsDraggingMedia] = useState(false); const [draggingMetadataFileId, setDraggingMetadataFileId] = useState(null); // Warn user before leaving page while uploading useEffect(() => { if (!isUploading) return; const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); // Required in most browsers to trigger the confirmation dialog e.returnValue = ''; }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [isUploading]); // Parse config template from context (read from env in home.state.tsx) const configTemplate = useMemo(() => { if (chatUploadFileConfigTemplateJson) { try { return JSON.parse(chatUploadFileConfigTemplateJson); } catch (error) { console.warn('Failed to parse upload file config template:', error); } } return null; }, [chatUploadFileConfigTemplateJson]); // Generate default form data from config template const generateDefaultFormData = useCallback((): Record => { if (!configTemplate || !Array.isArray(configTemplate.fields)) return {}; return configTemplate.fields.reduce((acc, field) => { acc[field['field-name']] = field['field-default-value']; return acc; }, {} as Record); }, [configTemplate]); // Generate unique ID for file const generateFileId = useCallback(() => { return `file_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; }, []); // Create FileWithFormData from File const createFileWithFormData = useCallback((file: File): FileWithFormData => ({ id: generateFileId(), file, formData: generateDefaultFormData(), isExpanded: false, }), [generateFileId, generateDefaultFormData]); // Get field value from formData or default const getFieldValue = useCallback((formData: Record, field: UploadFileFieldConfig) => { return formData[field['field-name']] ?? field['field-default-value']; }, []); const triggerUpload = useCallback(() => { if (disabled || isUploading) return; setShowFileSelectPopup(true); }, [disabled, isUploading]); // Directly open the native file picker dialog const triggerFilePicker = useCallback(() => { if (disabled || isUploading) return; videoInputRef.current?.click(); }, [disabled, isUploading]); const handleCancelFileSelect = useCallback(() => { setShowFileSelectPopup(false); setSelectedFiles([]); }, []); // Check if file is an allowed video format (only .mp4 and .mkv) const isAllowedVideoFile = useCallback((file: File) => { const allowedExtensions = /\.(mp4|mkv)$/i; const allowedMimeTypes = ['video/mp4', 'video/x-matroska']; return allowedExtensions.test(file.name) || allowedMimeTypes.includes(file.type); }, []); // Shared logic to process dropped/selected files const processDroppedFiles = useCallback((files: FileList | File[], openPopup = false) => { const allFiles = Array.from(files); const validFiles = allFiles.filter(isAllowedVideoFile); const hasInvalidFiles = allFiles.length > validFiles.length; if (hasInvalidFiles) { toast.error('Please drop video files only (mp4, mkv)'); } if (validFiles.length > 0) { const newFiles = validFiles.map(createFileWithFormData); setSelectedFiles(prev => [...prev, ...newFiles]); if (openPopup) { setShowFileSelectPopup(true); } } }, [createFileWithFormData, isAllowedVideoFile]); const handleVideoFileChange = useCallback((event: React.ChangeEvent) => { const files = event.target.files; if (files && files.length > 0) { processDroppedFiles(files, true); } event.target.value = ''; }, [processDroppedFiles]); const handleRemoveFile = useCallback((fileId: string) => { setSelectedFiles(prev => prev.filter(f => f.id !== fileId)); }, []); const handleToggleFileExpand = useCallback((fileId: string) => { setSelectedFiles(prev => prev.map(f => f.id === fileId ? { ...f, isExpanded: !f.isExpanded } : f )); }, []); const handleFileFormDataChange = useCallback((fileId: string, fieldName: string, value: any) => { setSelectedFiles(prev => prev.map(f => f.id === fileId ? { ...f, formData: { ...f.formData, [fieldName]: value } } : f )); }, []); // Toggle metadata section for a file const handleToggleFileMetadataExpand = useCallback((fileId: string) => { setSelectedFiles(prev => prev.map(f => f.id === fileId ? { ...f, isMetadataExpanded: !f.isMetadataExpanded } : f )); }, []); // Validate and set metadata file for a specific file const validateAndSetFileMetadata = useCallback(async (fileId: string, file: File) => { if (!file.name.endsWith('.json')) { toast.error('Please select a JSON file'); return false; } try { const content = await file.text(); JSON.parse(content); setSelectedFiles(prev => prev.map(f => f.id === fileId ? { ...f, metadataFile: file } : f )); return true; } catch { toast.error('Invalid JSON format. Please check your file.'); return false; } }, []); // Remove metadata file from a specific file const handleRemoveFileMetadata = useCallback((fileId: string) => { setSelectedFiles(prev => prev.map(f => f.id === fileId ? { ...f, metadataFile: null } : f )); }, []); // Open file picker for metadata const handleMetadataFileSelect = useCallback((fileId: string) => { setPendingMetadataFileId(fileId); metadataInputRef.current?.click(); }, []); // Handle metadata file input change const handleMetadataInputChange = useCallback(async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file && pendingMetadataFileId) { await validateAndSetFileMetadata(pendingMetadataFileId, file); } event.target.value = ''; setPendingMetadataFileId(null); }, [pendingMetadataFileId, validateAndSetFileMetadata]); // Common drag prevention handler const preventDragDefault = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleMediaDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDraggingMedia(false); const files = e.dataTransfer.files; if (files && files.length > 0) { processDroppedFiles(files, false); } }, [processDroppedFiles]); // Handle metadata drop for a specific file const handleFileMetadataDrop = useCallback(async (fileId: string, e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDraggingMetadataFileId(null); const files = e.dataTransfer.files; if (files && files.length > 0) { await validateAndSetFileMetadata(fileId, files[0]); } }, [validateAndSetFileMetadata]); const handleConfirmUpload = useCallback(() => { if (selectedFiles.length === 0) { toast.error('Please select at least one file'); return; } processFilesParallel(selectedFiles); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedFiles]); const handleClosePopup = useCallback(() => { setShowSuccessPopup(false); setShowProgressPopup(false); setAllUploadResults([]); setUploadingFiles([]); setExpandedResults(new Set()); setCopiedResultIndex(null); }, []); const toggleResultExpanded = useCallback((index: number) => { setExpandedResults(prev => { const newSet = new Set(prev); if (newSet.has(index)) { newSet.delete(index); } else { newSet.add(index); } return newSet; }); }, []); const handleCopyJson = useCallback(async (text?: string, index?: number) => { const content = text ?? (allUploadResults.length > 0 ? JSON.stringify(allUploadResults, null, 2) : ''); if (content) { const success = await copyToClipboard(content); if (success) { if (index !== undefined) { setCopiedResultIndex(index); setTimeout(() => setCopiedResultIndex(null), 2000); } } } }, [allUploadResults]); // Drag and drop handlers const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragging(true); } }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDragging(false); } }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); dragCounterRef.current = 0; if (disabled || isUploading) return; const files = e.dataTransfer.files; if (files && files.length > 0) { processDroppedFiles(files, true); } }, [disabled, isUploading, processDroppedFiles]); const dragHandlers = { onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: preventDragDefault, onDrop: handleDrop, }; // Update uploading files progress (for progress popup) const updateUploadingFileProgress = useCallback((fileId: string, progress: number) => { setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, uploadProgress: progress } : f )); }, []); // Update uploading files status (for progress popup) const updateUploadingFileStatus = useCallback((fileId: string, status: FileUploadStatus, error?: string) => { setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, uploadStatus: status, uploadError: error } : f )); }, []); // Cancel a single file upload const handleCancelSingleUpload = useCallback((fileId: string) => { // Mark as cancelled to prevent upload from starting cancelledFileIdsRef.current.add(fileId); // Abort upload if in progress abortControllerMapRef.current.get(fileId)?.abort(); abortControllerMapRef.current.delete(fileId); // Update status immediately updateUploadingFileStatus(fileId, 'cancelled', 'Cancelled'); }, [updateUploadingFileStatus]); // Cancel all uploads const handleCancelAllUploads = useCallback(() => { // Mark all pending/uploading files as cancelled and update UI setUploadingFiles(prev => prev.map(f => { if (f.uploadStatus === 'pending' || f.uploadStatus === 'uploading') { cancelledFileIdsRef.current.add(f.id); return { ...f, uploadStatus: 'cancelled' as FileUploadStatus, uploadError: 'Cancelled' }; } return f; })); // Abort all uploads and clear map abortControllerMapRef.current.forEach(controller => controller.abort()); abortControllerMapRef.current.clear(); }, []); // Helper to check if file is cancelled const isFileCancelled = useCallback((fileId: string) => cancelledFileIdsRef.current.has(fileId), []); // Upload a single file (for progress popup) const uploadSingleFileWithTracking = async (fileItem: FileWithFormData): Promise<{ filename: string; result?: FileUploadResult; error?: string; cancelled?: boolean }> => { const { id: fileId, file, formData } = fileItem; const filename = file.name; const cancelledResult = { filename, error: 'Upload was cancelled', cancelled: true }; // Check if already cancelled before starting if (isFileCancelled(fileId)) { return cancelledResult; } if (!agentApiUrlBase) { const errorMessage = 'Agent API URL is not configured'; updateUploadingFileStatus(fileId, 'error', errorMessage); return { filename, error: errorMessage, cancelled: false }; } updateUploadingFileStatus(fileId, 'uploading'); updateUploadingFileProgress(fileId, 0); try { // Create AbortController for the upload const abortController = new AbortController(); abortControllerMapRef.current.set(fileId, abortController); // Use shared upload utility const result = await uploadFile( file, agentApiUrlBase, formData, (progress) => updateUploadingFileProgress(fileId, progress), abortController.signal ); // Clean up AbortController after successful upload abortControllerMapRef.current.delete(fileId); // Check if cancelled after upload if (isFileCancelled(fileId)) { return cancelledResult; } updateUploadingFileStatus(fileId, 'success'); updateUploadingFileProgress(fileId, 100); return { filename, result }; } catch (error) { // Clean up AbortController on error abortControllerMapRef.current.delete(fileId); const isAborted = error instanceof Error && (error.name === 'AbortError' || error.message === 'Upload was cancelled'); const isCancelled = isAborted || isFileCancelled(fileId); if (isCancelled) { return cancelledResult; } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; updateUploadingFileStatus(fileId, 'error', errorMessage); return { filename, error: errorMessage, cancelled: false }; } }; // Process all files in parallel const processFilesParallel = async (files: FileWithFormData[]) => { // Close file select popup and show progress popup setShowFileSelectPopup(false); setShowProgressPopup(true); setIsUploading(true); setAllUploadResults([]); // Clear cancelled file IDs from previous upload session cancelledFileIdsRef.current.clear(); // Initialize uploading files for progress popup const filesToUpload = files.map(f => ({ ...f, uploadStatus: 'pending' as FileUploadStatus, uploadProgress: 0, })); setUploadingFiles(filesToUpload); try { // Upload all files in parallel const results = await Promise.all( filesToUpload.map(fileItem => uploadSingleFileWithTracking(fileItem)) ); // Store all results setAllUploadResults(results); // Count successes, errors, and cancelled const successes = results.filter(r => r.result); const errors = results.filter(r => r.error && !r.cancelled); const cancelled = results.filter(r => r.cancelled); if (errors.length > 0) { errors.forEach(({ filename }) => { onUploadError?.(new Error(`Failed to upload ${filename}`)); }); } if (successes.length > 0) { successes.forEach(({ result }) => { if (result) onUploadSuccess?.(result); }); // Send hidden message to chat API with the uploaded video filenames if (onSendHiddenMessage && chatUploadFileHiddenMessageTemplate) { // Fallback order: result.filename -> result.video_id -> result.id -> original filename const videoFilenames = successes .map(({ filename, result }) => (result as any)?.filename || (result as any)?.video_id || (result as any)?.id || filename) .filter((name): name is string => !!name); if (videoFilenames.length > 0) { const filenamesStr = videoFilenames.join(' '); // Replace {filenames} placeholder with actual filenames const hiddenMessage = chatUploadFileHiddenMessageTemplate.replaceAll('{filenames}', filenamesStr); onSendHiddenMessage(hiddenMessage); } } } // Show success popup after a short delay (even if some were cancelled) setTimeout(() => { setShowProgressPopup(false); // Only show success popup if there were any results (not all cancelled) if (successes.length > 0 || errors.length > 0 || cancelled.length > 0) { setShowSuccessPopup(true); } }, 1000); // Clear selected files setSelectedFiles([]); } catch (error) { const err = error instanceof Error ? error : new Error('Unknown error'); toast.error(`Upload failed: ${err.message}`); onUploadError?.(err); setShowProgressPopup(false); } finally { setIsUploading(false); // Clear all remaining references abortControllerMapRef.current.clear(); cancelledFileIdsRef.current.clear(); } }; return ( <> {/* Hidden file inputs */} {children({ triggerUpload, triggerFilePicker, isUploading, uploadProgress: 0, isDragging, dragHandlers })} {/* File Selection Popup */} {showFileSelectPopup && (
{/* Title */}

Upload Files

{/* Media File Section */}
{selectedFiles.length > 0 && ( )}
{/* File List */} {selectedFiles.length > 0 ? (
{selectedFiles.map((fileItem) => (
{/* File Header */} {(() => { const hasExpandableContent = chatUploadFileMetadataEnabled || (configTemplate && Array.isArray(configTemplate.fields) && configTemplate.fields.length > 0); return ( <>
hasExpandableContent && handleToggleFileExpand(fileItem.id)} > {hasExpandableContent && ( )} {fileItem.file.name} ({(fileItem.file.size / 1024 / 1024).toFixed(2)} MB)
{/* File Form - Collapsible */} {hasExpandableContent && fileItem.isExpanded && (
{/* Form Fields */} {configTemplate && Array.isArray(configTemplate.fields) && configTemplate.fields.length > 0 && (
{configTemplate.fields.map((field) => { const value = getFieldValue(fileItem.formData, field); const fieldName = field['field-name']; const isChangeable = field['changeable'] !== false; const tooltipInfo = field['tooltip-info'] || ''; return (
{field['field-type'] === 'boolean' ? ( ) : field['field-type'] === 'select' ? ( ) : field['field-type'] === 'number' ? ( handleFileFormDataChange(fileItem.id, fieldName, Number(e.target.value))} className={`${INPUT_CLASS} ${!isChangeable ? 'cursor-not-allowed opacity-60' : ''}`} /> ) : ( handleFileFormDataChange(fileItem.id, fieldName, e.target.value)} className={`${INPUT_CLASS} ${!isChangeable ? 'cursor-not-allowed opacity-60' : ''}`} placeholder={`Enter ${fieldName}`} /> )}
); })}
)} {/* Metadata File Section - Per file (only show when enabled via env) */} {chatUploadFileMetadataEnabled && (
{/* Metadata Accordion Header */} {/* Metadata Content */} {fileItem.isMetadataExpanded && (
{fileItem.metadataFile ? (
{fileItem.metadataFile.name}
) : (
handleMetadataFileSelect(fileItem.id)} onDragOver={preventDragDefault} onDragEnter={(e) => { preventDragDefault(e); setDraggingMetadataFileId(fileItem.id); }} onDragLeave={(e) => { preventDragDefault(e); setDraggingMetadataFileId(null); }} onDrop={(e) => handleFileMetadataDrop(fileItem.id, e)} className={`w-full cursor-pointer rounded-lg border-2 border-dashed p-3 text-center transition-colors ${ draggingMetadataFileId === fileItem.id ? 'border-blue-500 bg-blue-500/10' : 'border-gray-300 hover:border-blue-500 hover:bg-gray-50 dark:border-gray-600 dark:hover:border-blue-500 dark:hover:bg-[#3d3d4a]' }`} > {draggingMetadataFileId === fileItem.id ? 'Drop JSON here' : 'Click or drag JSON metadata'}
)}
)}
)}
)} ); })()}
))}
) : (
{ preventDragDefault(e); setIsDraggingMedia(true); }} onDragLeave={(e) => { preventDragDefault(e); setIsDraggingMedia(false); }} onDrop={handleMediaDrop} className={`w-full cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${ isDraggingMedia ? 'border-[#76b900] bg-[#76b900]/10' : 'border-gray-300 hover:border-[#76b900] hover:bg-gray-50 dark:border-gray-600 dark:hover:border-[#76b900] dark:hover:bg-gray-800' }`} > {isDraggingMedia ? 'Drop files here' : 'Click or drag files here'}
Movie Files (mp4, mkv)
)}
{/* Buttons */}
)} {/* Progress Popup */} {showProgressPopup && (
{/* Title */}

Uploading Files...

{/* Cancel All Button */} {uploadingFiles.some(f => f.uploadStatus === 'pending' || f.uploadStatus === 'uploading') && (
)} {/* File Progress List */}
{uploadingFiles.map((fileItem) => (
{fileItem.uploadStatus === 'uploading' ? (
) : fileItem.uploadStatus === 'success' ? ( ) : fileItem.uploadStatus === 'error' ? ( ) : fileItem.uploadStatus === 'cancelled' ? ( ) : (
)} {fileItem.file.name}
{fileItem.uploadStatus === 'success' ? 'Done' : fileItem.uploadStatus === 'error' ? 'Failed' : fileItem.uploadStatus === 'cancelled' ? 'Cancelled' : fileItem.uploadStatus === 'uploading' ? `${fileItem.uploadProgress || 0}%` : 'Pending'} {/* Cancel button for uploading/pending files */} {(fileItem.uploadStatus === 'uploading' || fileItem.uploadStatus === 'pending') && ( )}
{/* Progress Bar */}
{fileItem.uploadError && (

{fileItem.uploadError}

)}
))}
)} {/* Success Popup */} {showSuccessPopup && allUploadResults.length > 0 && (() => { const successCount = allUploadResults.filter(r => r.result).length; const cancelledCount = allUploadResults.filter(r => r.cancelled).length; const failedCount = allUploadResults.length - successCount - cancelledCount; const totalCount = allUploadResults.length; // Determine overall status const allSuccess = successCount === totalCount; const allFailed = failedCount === totalCount; const allCancelled = cancelledCount === totalCount; return (
{/* Status Icon - changes based on result */}
{allSuccess ? ( ) : allFailed ? ( ) : allCancelled ? ( ) : ( )}
{/* Title - changes based on result */}

{allSuccess ? 'Upload Complete!' : allFailed ? 'Upload Failed' : allCancelled ? 'Upload Cancelled' : 'Upload Partially Complete'}

{/* Description */}

{successCount} / {totalCount} files uploaded successfully {cancelledCount > 0 && ( ({cancelledCount} cancelled) )} {failedCount > 0 && ( ({failedCount} failed) )}

{/* File Results List */}
{allUploadResults.map((item, index) => (
{/* File Header - Clickable to expand/collapse */} {/* JSON Response or Error - Collapsible */} {expandedResults.has(index) && (
                          {item.result 
                            ? JSON.stringify(item.result, null, 2)
                            : item.cancelled
                              ? 'Upload was cancelled'
                              : `Error: ${item.error}`
                          }
                        
)}
))}
{/* Button */}
); })()} ); }; export default ChatFileUpload; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Chat/ChatHeader.tsx ================================================ 'use client'; import { IconArrowsSort, IconMobiledataOff, IconSun, IconMoonFilled, IconUserFilled, IconChevronLeft, IconChevronRight, IconUpload, } from '@tabler/icons-react'; import React, { useContext, useState, useRef, useEffect } from 'react'; import { useWorkflowName, useRightMenuOpenDefault } from '@/contexts/RuntimeConfigContext'; import ChatFileUpload from '@/components/Chat/ChatFileUpload'; import HomeContext from '@/pages/api/home/home.context'; import { Message } from '@/types/chat'; interface ChatHeaderProps { webSocketModeRef?: React.MutableRefObject | Record; onSend?: (message: Message) => void; } export const ChatHeader = ({ webSocketModeRef = {}, onSend }: ChatHeaderProps) => { const [isMenuOpen, setIsMenuOpen] = useState(false); const rightMenuOpenDefault = useRightMenuOpenDefault(); const [isExpanded, setIsExpanded] = useState(rightMenuOpenDefault); const menuRef = useRef(null); const workflow = useWorkflowName(); const { state: { chatHistory, webSocketMode, webSocketConnected, lightMode, selectedConversation, chatUploadFileEnabled, themeChangeButtonEnabled, }, dispatch: homeDispatch, } = useContext(HomeContext); const handleLogin = () => { console.log('Login clicked'); setIsMenuOpen(false); }; useEffect(() => { const handleClickOutside = (event) => { if (menuRef.current && !menuRef.current.contains(event.target)) { setIsMenuOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const hasMessages = selectedConversation?.messages?.length > 0; // Shared content for the header const renderHeaderContent = (uploadProps?: { triggerFilePicker: () => void; isUploading: boolean; isDragging: boolean; dragHandlers: any; }) => (
{hasMessages ? (
{workflow}
) : ( /* Welcome screen */
Hi, I'm {workflow}
How can I assist you today?
{/* File Upload Drop Zone - only show when upload is enabled */} {chatUploadFileEnabled && uploadProps && (

{uploadProps.isDragging ? 'Drop files here' : 'Click or drop files here to upload'}

{/* File type hints */}

Movie Files (mp4, mkv)

)}
)} {/* Collapsible Menu - opaque background so it hides the title when expanded */}
{/* Chat History Toggle */}
{/* WebSocket Mode Toggle */}
{/* Theme Toggle Button */} {themeChangeButtonEnabled && (
)} {/* User Icon with Dropdown Menu */}
{isMenuOpen && (
)}
); // Conditionally wrap with ChatFileUpload when upload is enabled if (chatUploadFileEnabled) { return ( { onSend({ role: 'user', content: message, hidden: true }); } : undefined} > {({ triggerFilePicker, isUploading, isDragging, dragHandlers }) => renderHeaderContent({ triggerFilePicker, isUploading, isDragging, dragHandlers }) } ); } return renderHeaderContent(); }; ================================================ FILE: ui/packages/nemo-agent-toolkit-ui/components/Chat/ChatInput.tsx ================================================ import { IconArrowDown, IconBolt, IconPaperclip, IconPhoto, IconPlayerStop, IconRepeat, IconSend, IconTrash, IconMicrophone, IconPlayerStopFilled, IconMicrophone2, IconUpload, IconBrain, } from '@tabler/icons-react'; import { KeyboardEvent, MutableRefObject, Ref, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import toast from 'react-hot-toast'; import { useTranslation } from 'next-i18next'; import { useWorkflowName } from '@/contexts/RuntimeConfigContext'; import { appConfig } from '@/utils/app/const'; import { compressImage } from '@/utils/app/helper'; import { Message } from '@/types/chat'; import HomeContext from '@/pages/api/home/home.context'; import { ChatFileUpload } from './ChatFileUpload'; import { CustomAgentParams, CustomAgentParamsValues, ParamField, useInitialParamFields, fieldsToParams, } from './CustomAgentParams'; interface Props { onSend: (message: Message, customParams?: CustomAgentParamsValues) => void; onRegenerate: () => void; onScrollDownClick: () => void; textareaRef: MutableRefObject; showScrollDownButton: boolean; controller: Ref; onStopConversation: () => void; } export const ChatInput = ({ onSend, onRegenerate, onScrollDownClick, textareaRef, showScrollDownButton, controller, onStopConversation, }: Props) => { const { t } = useTranslation('chat'); const { state: { selectedConversation, messageIsStreaming, loading, webSocketMode, customAgentParamsJson, chatUploadFileEnabled, chatInputMicEnabled }, dispatch: homeDispatch, } = useContext(HomeContext); const workflow = useWorkflowName(); // Create audio only when the file is present const [recordingStartSound, setRecordingStartSound] = useState